diff --git a/lib/block-supports/spacing.php b/lib/block-supports/spacing.php index 86e6c750185a56..044654b83959f2 100644 --- a/lib/block-supports/spacing.php +++ b/lib/block-supports/spacing.php @@ -73,6 +73,71 @@ function gutenberg_apply_spacing_support( $block_type, $block_attributes ) { return $attributes; } +/** + * Returns a spacing size value based on a given spacing size preset. + * + * @since TBD + * + * @param array $preset { + * Required. spacingSizes preset value as seen in theme.json. + * + * @type string $name Name of the spacing size preset. + * @type string $slug Kebab-case unique identifier for the spacing size preset. + * @type string|int|float $size CSS spacing size value, including units where applicable. + * @type array $fluid Fluid spacing settings when fluid spacing is enabled. + * } + * @param bool|array $settings Optional Theme JSON settings array that overrides any global theme settings. + * Default is `array()`. + * + * @return string|null Spacing size value or `null` if a size is not passed in $preset. + */ +function gutenberg_get_spacing_size_value( $preset, $settings = array() ) { + if ( ! isset( $preset['size'] ) ) { + return null; + } + + // Treat existing functional CSS size as authoritative; return as-is. + $size = isset( $preset['size'] ) ? (string) $preset['size'] : ''; + if ( + false !== strpos( $size, 'clamp(' ) || + false !== strpos( $size, 'min(' ) || + false !== strpos( $size, 'max(' ) || + false !== strpos( $size, 'calc(' ) || + false !== strpos( $size, 'var(' ) + ) { + return $size; + } + + // Fluid settings from the preset (can be false|true|array). + $fluid_spacing_settings = $preset['fluid'] ?? null; + + // Explicitly disabled or empty size → return static size. + if ( false === $fluid_spacing_settings || empty( $size ) ) { + return $size; + } + + // If fluid is boolean true (no explicit values), return static size for spacing. + // @todo: We need to create a fluid calculation for spacing (or use the same one as typography) + if ( true === $fluid_spacing_settings ) { + return $size; + } + + // Build clamp() only when explicit min/preferred/max are provided via fluid object. + if ( is_array( $fluid_spacing_settings ) && ! empty( $fluid_spacing_settings ) ) { + $min = isset( $fluid_spacing_settings['min'] ) ? trim( (string) $fluid_spacing_settings['min'] ) : ''; + $preferred = isset( $fluid_spacing_settings['preferred'] ) ? trim( (string) $fluid_spacing_settings['preferred'] ) : trim( (string) $size ); + $max = isset( $fluid_spacing_settings['max'] ) ? trim( (string) $fluid_spacing_settings['max'] ) : ''; + + // All three are required to form a valid clamp(). + if ( '' !== $min && '' !== $preferred && '' !== $max ) { + return sprintf( 'clamp(%s, %s, %s)', $min, $preferred, $max ); + } + } + + // Fallback to static size. + return $size; +} + // Register the block support. WP_Block_Supports::get_instance()->register( 'spacing', diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index b6a1e7a2029758..ebdb87cbd369a6 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -190,7 +190,7 @@ class WP_Theme_JSON_Gutenberg { 'path' => array( 'spacing', 'spacingSizes' ), 'prevent_override' => array( 'spacing', 'defaultSpacingSizes' ), 'use_default_names' => true, - 'value_key' => 'size', + 'value_func' => 'gutenberg_get_spacing_size_value', 'css_vars' => '--wp--preset--spacing--$slug', 'classes' => array(), 'properties' => array( 'padding', 'margin' ), diff --git a/packages/block-editor/src/components/global-styles/get-global-styles-changes.js b/packages/block-editor/src/components/global-styles/get-global-styles-changes.js index 264426d83a1052..e93dd88765b7b7 100644 --- a/packages/block-editor/src/components/global-styles/get-global-styles-changes.js +++ b/packages/block-editor/src/components/global-styles/get-global-styles-changes.js @@ -26,6 +26,7 @@ const translationMap = { 'settings.typography': __( 'Typography' ), 'settings.shadow': __( 'Shadow' ), 'settings.layout': __( 'Layout' ), + 'settings.spacing': __( 'Spacing' ), 'styles.color': __( 'Colors' ), 'styles.spacing': __( 'Spacing' ), 'styles.background': __( 'Background' ), diff --git a/packages/edit-site/src/components/global-styles/screen-layout.js b/packages/edit-site/src/components/global-styles/screen-layout.js index b6fa9f18f18dee..6b6a082f7b8627 100644 --- a/packages/edit-site/src/components/global-styles/screen-layout.js +++ b/packages/edit-site/src/components/global-styles/screen-layout.js @@ -9,6 +9,8 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; */ import DimensionsPanel from './dimensions-panel'; import ScreenHeader from './header'; +import SpacingsCount from './spacing/spacings-count'; +import { hasAvailableSpacingSizes } from './spacing/utils'; import { unlock } from '../../lock-unlock'; const { useHasDimensionsPanel, useGlobalSetting, useSettingsForBlockElement } = @@ -18,11 +20,19 @@ function ScreenLayout() { const [ rawSettings ] = useGlobalSetting( '' ); const settings = useSettingsForBlockElement( rawSettings ); const hasDimensionsPanel = useHasDimensionsPanel( settings ); + const hasSpacingSizes = hasAvailableSpacingSizes( settings ); return ( <> - { hasDimensionsPanel && } + <> + { hasDimensionsPanel && } + { hasSpacingSizes && ( +
+ +
+ ) } + ); } diff --git a/packages/edit-site/src/components/global-styles/size-control/index.js b/packages/edit-site/src/components/global-styles/size-control/index.js index 06ea0bb5617e31..1e5d49dade1ca5 100644 --- a/packages/edit-site/src/components/global-styles/size-control/index.js +++ b/packages/edit-site/src/components/global-styles/size-control/index.js @@ -25,21 +25,47 @@ function SizeControl( { ...props } ) { const { baseControlProps } = useBaseControlProps( props ); - const { value, onChange, fallbackValue, disabled, label } = props; + const { value, onChange, fallbackValue, disabled, label, max } = props; const units = useCustomUnits( { availableUnits: DEFAULT_UNITS, } ); + const maxRelativeValue = 10; + const maxNonRelativeValue = 100; + + // Helper function to check if a unit is relative + const isUnitRelative = ( unit ) => + !! unit && [ 'em', 'rem', 'vw', 'vh' ].includes( unit ); + const [ valueQuantity, valueUnit = 'px' ] = parseQuantityAndUnitFromRawValue( value, units ); - const isValueUnitRelative = - !! valueUnit && [ 'em', 'rem', 'vw', 'vh' ].includes( valueUnit ); + const isValueUnitRelative = isUnitRelative( valueUnit ); + + // Determine the max value for the range control + const getMaxValue = () => { + // For relative units, always use 10 + if ( isValueUnitRelative ) { + return maxRelativeValue; + } + // For non-relative units, use custom max from props or default 500 + return max !== undefined ? max : maxNonRelativeValue; + }; // Receives the new value from the UnitControl component as a string containing the value and unit. const handleUnitControlChange = ( newValue ) => { - onChange( newValue ); + const [ newQuantity, newUnit = 'px' ] = + parseQuantityAndUnitFromRawValue( newValue, units ); + + const isNewUnitRelative = isUnitRelative( newUnit ); + + // If switching to a relative unit and the value exceeds the max, clamp it + if ( isNewUnitRelative && newQuantity > maxRelativeValue ) { + onChange( maxRelativeValue + newUnit ); + } else { + onChange( newValue ); + } }; // Receives the new value from the RangeControl component as a number. @@ -75,7 +101,7 @@ function SizeControl( { withInputField={ false } onChange={ handleRangeControlChange } min={ 0 } - max={ isValueUnitRelative ? 10 : 100 } + max={ getMaxValue() } step={ isValueUnitRelative ? 0.1 : 1 } disabled={ disabled } /> diff --git a/packages/edit-site/src/components/global-styles/spacing/confirm-delete-spacing-dialog.js b/packages/edit-site/src/components/global-styles/spacing/confirm-delete-spacing-dialog.js new file mode 100644 index 00000000000000..9672065c49c5b5 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/spacing/confirm-delete-spacing-dialog.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components'; + +function ConfirmDeleteSpacingDialog( { + spacingSize, + isOpen, + toggleOpen, + handleRemoveSpacingSize, +} ) { + const handleConfirm = () => { + handleRemoveSpacingSize(); + toggleOpen(); + }; + + const handleCancel = () => { + toggleOpen(); + }; + + return ( + + { spacingSize && + sprintf( + /* translators: %s: Name of the spacing size preset. */ + __( + 'Are you sure you want to delete "%s" spacing size preset?' + ), + spacingSize.name + ) } + + ); +} + +export default ConfirmDeleteSpacingDialog; diff --git a/packages/edit-site/src/components/global-styles/spacing/confirm-reset-spacings-dialog.js b/packages/edit-site/src/components/global-styles/spacing/confirm-reset-spacings-dialog.js new file mode 100644 index 00000000000000..c09240bc2bca93 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/spacing/confirm-reset-spacings-dialog.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components'; + +function ConfirmResetSpacingsDialog( { + text, + confirmButtonText, + isOpen, + toggleOpen, + onConfirm, +} ) { + const handleConfirm = () => { + onConfirm(); + toggleOpen(); + }; + + const handleCancel = () => { + toggleOpen(); + }; + + if ( ! isOpen ) { + return null; + } + + return ( + + { text } + + ); +} + +export default ConfirmResetSpacingsDialog; diff --git a/packages/edit-site/src/components/global-styles/spacing/rename-spacing-dialog.js b/packages/edit-site/src/components/global-styles/spacing/rename-spacing-dialog.js new file mode 100644 index 00000000000000..9b4b653544e810 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/spacing/rename-spacing-dialog.js @@ -0,0 +1,79 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + Button, + Modal, + __experimentalInputControl as InputControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { useState } from '@wordpress/element'; + +function RenameSpacingDialog( { spacingSize, toggleOpen, handleRename } ) { + const [ editedName, setEditedName ] = useState( spacingSize.name ); + + const handleConfirm = () => { + if ( editedName !== spacingSize.name ) { + handleRename( editedName ); + } + toggleOpen(); + }; + + const handleCancel = () => { + toggleOpen(); + }; + + return ( + +
{ + event.preventDefault(); + handleConfirm(); + } } + > + + + + + + + +
+
+ ); +} + +export default RenameSpacingDialog; diff --git a/packages/edit-site/src/components/global-styles/spacing/spacing-preview.js b/packages/edit-site/src/components/global-styles/spacing/spacing-preview.js new file mode 100644 index 00000000000000..1d79b1b302696f --- /dev/null +++ b/packages/edit-site/src/components/global-styles/spacing/spacing-preview.js @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import { parseComparisonValue } from './utils'; + +/** + * Renders a preview of a spacing size with visual representation. + * + * @param {Object} props Component props. + * @param {Object} props.spacingSize The spacing size object. + * @return {Element} The spacing preview component. + */ +function SpacingPreview( { spacingSize } ) { + // Handle fluid spacing values + const spacingValue = spacingSize?.size || '1rem'; + + // Parse CSS comparison functions (clamp, min, max) + const { displayValue, previewValue } = parseComparisonValue( spacingValue ); + + const hasFluidObject = + spacingSize?.fluid && + typeof spacingSize.fluid === 'object' && + spacingSize.fluid.min && + spacingSize.fluid.preferred && + spacingSize.fluid.max; + + let boxSize = previewValue; + let label = displayValue; + if ( hasFluidObject ) { + const { min, preferred, max } = spacingSize.fluid; + const preferredValue = preferred || spacingSize.size || '1rem'; + boxSize = preferredValue; + label = `${ min } → ${ max }`; + } + + return ( +
+
+
{ label }
+
+ ); +} + +export default SpacingPreview; diff --git a/packages/edit-site/src/components/global-styles/spacing/spacing.js b/packages/edit-site/src/components/global-styles/spacing/spacing.js new file mode 100644 index 00000000000000..0a909baf49a7ee --- /dev/null +++ b/packages/edit-site/src/components/global-styles/spacing/spacing.js @@ -0,0 +1,342 @@ +/** + * WordPress dependencies + */ +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { __, sprintf } from '@wordpress/i18n'; +import { + __experimentalSpacer as Spacer, + useNavigator, + __experimentalView as View, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + privateApis as componentsPrivateApis, + Button, + FlexItem, + ToggleControl, +} from '@wordpress/components'; +import { moreVertical } from '@wordpress/icons'; +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../../lock-unlock'; +import ScreenHeader from '../header'; +import SpacingPreview from './spacing-preview'; +import ConfirmDeleteSpacingDialog from './confirm-delete-spacing-dialog'; +import RenameSpacingDialog from './rename-spacing-dialog'; +import SizeControl from '../size-control'; +import { parseClampValue, generateClampValue } from './utils'; + +const { Menu } = unlock( componentsPrivateApis ); +const { useGlobalSetting } = unlock( blockEditorPrivateApis ); + +function Spacing() { + const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState( false ); + const [ isRenameDialogOpen, setIsRenameDialogOpen ] = useState( false ); + + const { + params: { origin, slug }, + goBack, + } = useNavigator(); + + const [ spacingSizes, setSpacingSizes ] = useGlobalSetting( + 'spacing.spacingSizes' + ); + + // Get the spacing sizes from the origin, default to empty array. + const sizes = spacingSizes[ origin ] ?? []; + + // Get the spacing size by slug. + const originalSpacingSize = sizes.find( ( size ) => size.slug === slug ); + + // Navigate to the spacing sizes list if the spacing size is not available. + useEffect( () => { + if ( !! slug && ! originalSpacingSize ) { + goBack(); + } + }, [ slug, originalSpacingSize, goBack ] ); + + if ( ! origin || ! slug || ! originalSpacingSize ) { + return null; + } + + // Parse clamp value if it exists + const clampValues = parseClampValue( originalSpacingSize.size ); + const isClampValue = !! clampValues; + + // Create normalized spacingSize for UI purposes + let spacingSize = originalSpacingSize; + if ( isClampValue ) { + spacingSize = { + ...originalSpacingSize, + size: clampValues.min, + fluid: { + min: clampValues.min, + preferred: clampValues.preferred, + max: clampValues.max, + }, + }; + } + + // Whether the spacing size is fluid - check the original spacing size + // If fluid property is explicitly set, use that value. + // If no fluid property but there's a clamp value, default to true (fluid). + // Otherwise default to false. + const isFluid = + originalSpacingSize?.fluid !== undefined + ? !! originalSpacingSize.fluid + : isClampValue; + + // Whether custom fluid values are used - check the original spacing size + // For clamp values without explicit fluid setting, they are considered custom fluid + const isCustomFluid = + typeof originalSpacingSize?.fluid === 'object' || + ( isClampValue && originalSpacingSize?.fluid === undefined ); + + const handleNameChange = ( value ) => { + updateSpacingSize( 'name', value ); + }; + + const handleSpacingSizeChange = ( value ) => { + updateSpacingSize( 'size', value ); + }; + + const handleFluidChange = ( value ) => { + updateSpacingSize( 'fluid', value ); + }; + + const handleCustomFluidValues = ( value ) => { + if ( value ) { + // If custom values are used, init the values with the current ones. + updateSpacingSize( 'fluid', { + min: spacingSize.size, + preferred: spacingSize.size, + max: spacingSize.size, + } ); + } else { + // If custom fluid values are disabled, set fluid to true. + updateSpacingSize( 'fluid', true ); + } + }; + + const handleMinChange = ( value ) => { + updateSpacingSize( 'fluid', { ...spacingSize.fluid, min: value } ); + }; + + const handlePreferredChange = ( value ) => { + updateSpacingSize( 'fluid', { + ...spacingSize.fluid, + preferred: value, + } ); + }; + + const handleMaxChange = ( value ) => { + updateSpacingSize( 'fluid', { ...spacingSize.fluid, max: value } ); + }; + + const updateSpacingSize = ( key, value ) => { + const newSpacingSizes = sizes.map( ( size ) => { + if ( size.slug === slug ) { + let updatedSize = { ...size, [ key ]: value }; + + // Handle clamp values specially + if ( isClampValue ) { + if ( key === 'fluid' && typeof value === 'object' ) { + // Convert fluid object back to clamp format + const clampValue = generateClampValue( + value.min, + value.preferred, + value.max + ); + updatedSize = { ...updatedSize, size: clampValue }; + } else if ( key === 'fluid' && value === false ) { + // When disabling fluid, convert to min value + updatedSize = { + ...updatedSize, + size: clampValues.min, + fluid: false, + }; + } else if ( key === 'fluid' && value === true ) { + // When enabling simple fluid, keep original clamp + updatedSize = { + ...updatedSize, + size: generateClampValue( + clampValues.min, + clampValues.preferred, + clampValues.max + ), + fluid: true, + }; + } + } + + return updatedSize; + } + return size; + } ); + + setSpacingSizes( { + ...spacingSizes, + [ origin ]: newSpacingSizes, + } ); + }; + + const handleRemoveSpacingSize = () => { + const newSpacingSizes = sizes.filter( ( size ) => size.slug !== slug ); + setSpacingSizes( { + ...spacingSizes, + [ origin ]: newSpacingSizes, + } ); + }; + + const toggleDeleteConfirm = () => { + setIsDeleteConfirmOpen( ! isDeleteConfirmOpen ); + }; + + const toggleRenameDialog = () => { + setIsRenameDialogOpen( ! isRenameDialogOpen ); + }; + + return ( + <> + + + { isRenameDialogOpen && ( + + ) } + + + + + { origin === 'custom' && ( + + + + + } + /> + + + + { __( 'Rename' ) } + + + + + { __( 'Delete' ) } + + + + + + + ) } + + + + + + + + + + + + + + { isFluid && ( + + ) } + + { isFluid && isCustomFluid && ( + <> + + + + + ) } + + + + + + ); +} + +export default Spacing; diff --git a/packages/edit-site/src/components/global-styles/spacing/spacings-count.js b/packages/edit-site/src/components/global-styles/spacing/spacings-count.js new file mode 100644 index 00000000000000..763a313f9d3600 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/spacing/spacings-count.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { __, isRTL } from '@wordpress/i18n'; +import { + __experimentalItemGroup as ItemGroup, + __experimentalVStack as VStack, + __experimentalHStack as HStack, + FlexItem, +} from '@wordpress/components'; +import { Icon, chevronLeft, chevronRight } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Subtitle from '../subtitle'; +import { NavigationButtonAsItem } from '../navigation-button'; + +function Spacings() { + return ( + + + { __( 'Spacing Sizes' ) } + + + + + { __( 'Spacing size presets' ) } + + + + + + ); +} + +export default Spacings; diff --git a/packages/edit-site/src/components/global-styles/spacing/spacings.js b/packages/edit-site/src/components/global-styles/spacing/spacings.js new file mode 100644 index 00000000000000..03ac94b0d24a83 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/spacing/spacings.js @@ -0,0 +1,261 @@ +/** + * WordPress dependencies + */ +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { __, sprintf, isRTL } from '@wordpress/i18n'; +import { + privateApis as componentsPrivateApis, + __experimentalSpacer as Spacer, + __experimentalView as View, + __experimentalItemGroup as ItemGroup, + __experimentalVStack as VStack, + __experimentalHStack as HStack, + FlexItem, + Button, +} from '@wordpress/components'; +import { + Icon, + plus, + moreVertical, + chevronLeft, + chevronRight, +} from '@wordpress/icons'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../../lock-unlock'; +import Subtitle from '../subtitle'; +import { NavigationButtonAsItem } from '../navigation-button'; +import { getNewIndexFromPresets } from '../utils'; +import ScreenHeader from '../header'; +import ConfirmResetSpacingsDialog from './confirm-reset-spacings-dialog'; + +const { Menu } = unlock( componentsPrivateApis ); +const { useGlobalSetting } = unlock( blockEditorPrivateApis ); + +function SpacingGroup( { + label, + origin, + spacings, + handleAddSpacing, + handleResetSpacings, +} ) { + const [ isResetDialogOpen, setIsResetDialogOpen ] = useState( false ); + + const toggleResetDialog = () => setIsResetDialogOpen( ! isResetDialogOpen ); + + const resetDialogText = + origin === 'custom' + ? __( + 'Are you sure you want to remove all custom spacing presets?' + ) + : __( + 'Are you sure you want to reset all spacing presets to their default values?' + ); + + return ( + <> + { isResetDialogOpen && ( + + ) } + + + { label } + + { origin === 'custom' && ( +