diff --git a/lib/block-supports/dimensions.php b/lib/block-supports/dimensions.php index f96567dad0b12e..c20f5cdd14ae2b 100644 --- a/lib/block-supports/dimensions.php +++ b/lib/block-supports/dimensions.php @@ -61,7 +61,19 @@ function gutenberg_apply_dimensions_support( $block_type, $block_attributes ) { } } - // Width support to be added in near future. + // Width. + + // Width support flag can be true|false|"segmented" cannot use + // `gutenberg_block_has_support` which checked for boolean true or array. + $has_width_support = _wp_array_get( $block_type->supports, array( '__experimentalDimensions', 'width' ), false ); + + if ( $has_width_support ) { + $width_value = _wp_array_get( $block_attributes, array( 'style', 'dimensions', 'width' ), null ); + + if ( null !== $width_value ) { + $styles[] = sprintf( 'width: %s;', $width_value ); + } + } return empty( $styles ) ? array() : array( 'style' => implode( ' ', $styles ) ); } diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index bd97330e070c80..f4a17ff708c963 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -62,6 +62,7 @@ class WP_Theme_JSON_Gutenberg { ), 'dimensions' => array( 'height' => null, + 'width' => null, ), 'filter' => array( 'duotone' => null, @@ -104,6 +105,7 @@ class WP_Theme_JSON_Gutenberg { 'custom' => null, 'dimensions' => array( 'height' => null, + 'width' => null, ), 'layout' => array( 'contentSize' => null, @@ -238,7 +240,6 @@ class WP_Theme_JSON_Gutenberg { 'font-size' => array( 'typography', 'fontSize' ), 'font-style' => array( 'typography', 'fontStyle' ), 'font-weight' => array( 'typography', 'fontWeight' ), - 'height' => array( 'dimensions', 'height' ), 'letter-spacing' => array( 'typography', 'letterSpacing' ), 'line-height' => array( 'typography', 'lineHeight' ), 'margin' => array( 'spacing', 'margin' ), @@ -255,6 +256,8 @@ class WP_Theme_JSON_Gutenberg { 'text-decoration' => array( 'typography', 'textDecoration' ), 'text-transform' => array( 'typography', 'textTransform' ), 'filter' => array( 'filter', 'duotone' ), + 'height' => array( 'dimensions', 'height' ), + 'width' => array( 'dimensions', 'width' ), ); /** diff --git a/lib/theme.json b/lib/theme.json index 2d0ba992c8bc8c..fc91346e7e3f19 100644 --- a/lib/theme.json +++ b/lib/theme.json @@ -213,7 +213,8 @@ ] }, "dimensions": { - "height": false + "height": false, + "width": false }, "spacing": { "blockGap": null, diff --git a/packages/block-editor/src/components/width-control/index.js b/packages/block-editor/src/components/width-control/index.js new file mode 100644 index 00000000000000..b59828b728737d --- /dev/null +++ b/packages/block-editor/src/components/width-control/index.js @@ -0,0 +1,130 @@ +/** + * WordPress dependencies + */ +import { + Button, + ButtonGroup, + __experimentalUnitControl as UnitControl, +} from '@wordpress/components'; +import { useEffect, useRef, useState } from '@wordpress/element'; +import { edit } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +const DEFAULT_WIDTHS = [ '25%', '50%', '75%', '100%' ]; +const DEFAULT_UNIT = '%'; +const MIN_WIDTH = 0; + +/** + * Determines the CSS unit within the supplied width value. + * + * @param {string} value Value including CSS unit. + * @param {Array} units Available CSS units to validate against. + * + * @return {string} CSS unit extracted from supplied value. + */ +const parseUnit = ( value, units ) => { + let unit = String( value ) + .trim() + .match( /[\d.\-\+]*\s*(.*)/ )[ 1 ]; + + if ( ! unit ) { + return DEFAULT_UNIT; + } + + unit = unit.toLowerCase(); + unit = units.find( ( item ) => item.value === unit ); + + return unit?.value || DEFAULT_UNIT; +}; + +/** + * Width control that will display as either a simple `UnitControl` or a + * segmented control containing preset percentage widths. The segmented version + * contains a toggle to switch to a UnitControl and Slider for explicit control. + * + * @param {Object} props Component props. + * @return {WPElement} Width control. + */ +export default function WidthControl( props ) { + const { + label = __( 'Width' ), + onChange, + units, + value, + isSegmentedControl = false, + min = MIN_WIDTH, + presetWidths = DEFAULT_WIDTHS, + } = props; + + const ref = useRef(); + const hasCustomValue = value && ! presetWidths.includes( value ); + const [ customView, setCustomView ] = useState( hasCustomValue ); + const currentUnit = parseUnit( value, units ); + + // When switching to the custom view, move focus to the UnitControl. + useEffect( () => { + if ( customView && ref.current ) { + ref.current.focus(); + } + }, [ customView ] ); + + // Unless segmented control is desired return a normal UnitControl. + if ( ! isSegmentedControl ) { + return ( + + ); + } + + const toggleCustomView = () => { + setCustomView( ! customView ); + }; + + const handlePresetChange = ( selectedValue ) => { + const newWidth = selectedValue === value ? undefined : selectedValue; + onChange( newWidth ); + }; + + const renderCustomView = () => ( + + ); + + const renderPresetView = () => ( + + { presetWidths.map( ( width ) => ( + + ) ) } + + ); + + return ( +
+ { label } +
+ { customView ? renderCustomView() : renderPresetView() } +
+
+ ); +} diff --git a/packages/block-editor/src/components/width-control/style.scss b/packages/block-editor/src/components/width-control/style.scss new file mode 100644 index 00000000000000..1d2c04619e3837 --- /dev/null +++ b/packages/block-editor/src/components/width-control/style.scss @@ -0,0 +1,34 @@ +.components-width-control.is-segmented { + legend { + margin-bottom: $grid-unit-10; + } + + .components-width-control__wrapper { + display: flex; + align-items: center; + justify-content: space-between; + } + + .components-unit-control-wrapper { + flex: 1; + margin-right: $grid-unit-10; + max-width: 80px; + } + + .components-range-control { + flex: 1; + margin-bottom: 0; + + .components-base-control__field { + margin-bottom: 0; + height: 30px; + } + } + + .components-button.is-small.has-icon:not(.has-text) { + margin-left: $grid-unit-20; + min-width: 30px; + height: 30px; + padding: 0 4px; + } +} diff --git a/packages/block-editor/src/hooks/dimensions.js b/packages/block-editor/src/hooks/dimensions.js index bcf738d19975cf..af363b1eb966d7 100644 --- a/packages/block-editor/src/hooks/dimensions.js +++ b/packages/block-editor/src/hooks/dimensions.js @@ -38,6 +38,13 @@ import { resetPadding, useIsPaddingDisabled, } from './padding'; +import { + WidthEdit, + hasWidthSupport, + hasWidthValue, + resetWidth, + useIsWidthDisabled, +} from './width'; export const DIMENSIONS_SUPPORT_KEY = '__experimentalDimensions'; export const SPACING_SUPPORT_KEY = 'spacing'; @@ -55,6 +62,7 @@ export function DimensionsPanel( props ) { const isPaddingDisabled = useIsPaddingDisabled( props ); const isMarginDisabled = useIsMarginDisabled( props ); const isHeightDisabled = useIsHeightDisabled( props ); + const isWidthDisabled = useIsWidthDisabled( props ); const isDisabled = useIsDimensionsDisabled( props ); const isSupported = hasDimensionsSupport( props.name ); @@ -103,6 +111,21 @@ export function DimensionsPanel( props ) { ) } + { ! isWidthDisabled && ( + hasWidthValue( props ) } + label={ __( 'Width' ) } + onDeselect={ () => resetWidth( props ) } + resetAllFilter={ createResetAllFilter( + 'width', + 'dimensions' + ) } + isShownByDefault={ defaultDimensionsControls?.width } + panelId={ props.clientId } + > + + + ) } { ! isPaddingDisabled && ( hasPaddingValue( props ) } @@ -167,6 +190,7 @@ export function hasDimensionsSupport( blockName ) { return ( hasGapSupport( blockName ) || hasHeightSupport( blockName ) || + hasWidthSupport( blockName ) || hasPaddingSupport( blockName ) || hasMarginSupport( blockName ) ); @@ -181,10 +205,17 @@ export function hasDimensionsSupport( blockName ) { const useIsDimensionsDisabled = ( props = {} ) => { const gapDisabled = useIsGapDisabled( props ); const heightDisabled = useIsHeightDisabled( props ); + const widthDisabled = useIsWidthDisabled( props ); const paddingDisabled = useIsPaddingDisabled( props ); const marginDisabled = useIsMarginDisabled( props ); - return gapDisabled && heightDisabled && paddingDisabled && marginDisabled; + return ( + gapDisabled && + heightDisabled && + widthDisabled && + paddingDisabled && + marginDisabled + ); }; /** diff --git a/packages/block-editor/src/hooks/test/style.js b/packages/block-editor/src/hooks/test/style.js index aa177149e2625a..fea49a3ceb61ce 100644 --- a/packages/block-editor/src/hooks/test/style.js +++ b/packages/block-editor/src/hooks/test/style.js @@ -25,6 +25,7 @@ describe( 'getInlineStyles', () => { }, dimensions: { height: '500px', + width: '100%', }, spacing: { blockGap: '1em', @@ -45,6 +46,7 @@ describe( 'getInlineStyles', () => { height: '500px', marginBottom: '15px', paddingTop: '10px', + width: '100%', } ); } ); diff --git a/packages/block-editor/src/hooks/width.js b/packages/block-editor/src/hooks/width.js new file mode 100644 index 00000000000000..309b5bb8355452 --- /dev/null +++ b/packages/block-editor/src/hooks/width.js @@ -0,0 +1,131 @@ +/** + * WordPress dependencies + */ +import { getBlockSupport } from '@wordpress/blocks'; +import { __experimentalUseCustomUnits as useCustomUnits } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import WidthControl from '../components/width-control'; +import useSetting from '../components/use-setting'; +import { DIMENSIONS_SUPPORT_KEY } from './dimensions'; +import { cleanEmptyObject } from './utils'; + +/** + * Determines if there is width support. + * + * @param {string|Object} blockType Block name or Block Type object. + * @return {boolean} Whether there is support. + */ +export function hasWidthSupport( blockType ) { + const support = getBlockSupport( blockType, DIMENSIONS_SUPPORT_KEY ); + return !! ( true === support || support?.width ); +} + +/** + * Checks if there is a current value in the width block support attributes. + * + * @param {Object} props Block props. + * @return {boolean} Whether or not the block has a width value set. + */ +export function hasWidthValue( props ) { + return props.attributes.style?.dimensions?.width !== undefined; +} + +/** + * Checks whether the segmented width control was opted into via the block's + * support configuration. + * + * @param {string|Object} blockType Block name or Block Type object. + * @return {boolean} Whether width control should display as segmented control. + */ +export function useIsSegmentedControl( blockType ) { + const support = getBlockSupport( blockType, DIMENSIONS_SUPPORT_KEY ); + return support?.width === 'segmented'; +} + +/** + * Resets the width block support attributes. This can be used when + * disabling the width support controls for a block via a progressive + * discovery panel. + * + * @param {Object} props Block props. + * @param {Object} props.attributes Block's attributes. + * @param {Object} props.setAttributes Function to set block's attributes. + */ +export function resetWidth( { attributes = {}, setAttributes } ) { + const { style } = attributes; + + setAttributes( { + style: { + ...style, + dimensions: { + ...style?.dimensions, + width: undefined, + }, + }, + } ); +} + +/** + * Custom hook that checks if width controls have been disabled. + * + * @param {string} name The name of the block. + * @return {boolean} Whether width control is disabled. + */ +export function useIsWidthDisabled( { name: blockName } = {} ) { + const isDisabled = ! useSetting( 'dimensions.width' ); + return ! hasWidthSupport( blockName ) || isDisabled; +} + +/** + * Inspector control panel containing the width related configuration. + * + * @param {Object} props Block props. + * @return {WPElement} Edit component for width. + */ +export function WidthEdit( props ) { + const { + attributes: { style }, + name, + setAttributes, + } = props; + + const isSegmentedControl = useIsSegmentedControl( name ); + const units = useCustomUnits( { + availableUnits: useSetting( 'dimensions.units' ) || [ + '%', + 'px', + 'em', + 'rem', + 'vh', + 'vw', + ], + } ); + + if ( useIsWidthDisabled( props ) ) { + return null; + } + + const onChange = ( next ) => { + const newStyle = { + ...style, + dimensions: { + ...style?.dimensions, + width: next, + }, + }; + + setAttributes( { style: cleanEmptyObject( newStyle ) } ); + }; + + return ( + + ); +} diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index 826444fe652e0e..865ac6209c85f8 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -52,6 +52,7 @@ @import "./components/url-input/style.scss"; @import "./components/url-popover/style.scss"; @import "./components/warning/style.scss"; +@import "./components/width-control/style.scss"; @import "./hooks/anchor.scss"; @import "./hooks/layout.scss"; @import "./hooks/border.scss"; diff --git a/packages/blocks/src/api/constants.js b/packages/blocks/src/api/constants.js index 170d3a7e7cdf04..bd9d8dd1095684 100644 --- a/packages/blocks/src/api/constants.js +++ b/packages/blocks/src/api/constants.js @@ -116,6 +116,10 @@ export const __EXPERIMENTAL_STYLE_PROPERTY = { value: [ 'typography', 'letterSpacing' ], support: [ 'typography', '__experimentalLetterSpacing' ], }, + width: { + value: [ 'dimensions', 'width' ], + support: [ '__experimentalDimensions', 'width' ], + }, '--wp--style--block-gap': { value: [ 'spacing', 'blockGap' ], support: [ 'spacing', 'blockGap' ], diff --git a/packages/components/src/box-control/index.js b/packages/components/src/box-control/index.js index 5779b4a975b184..1b62b6bb29a75f 100644 --- a/packages/components/src/box-control/index.js +++ b/packages/components/src/box-control/index.js @@ -47,6 +47,7 @@ function useUniqueId( idProp ) { return idProp || instanceId; } export default function BoxControl( { + className, id: idProp, inputProps = defaultInputProps, onChange = noop, @@ -133,7 +134,12 @@ export default function BoxControl( { }; return ( - +
setHeightValue( undefined ); const hasHeightValue = () => !! heightValue; + // Width. + const [ widthValue, setWidthValue ] = useStyle( 'dimensions.width', name ); + const resetWidthValue = () => setWidthValue( undefined ); + const hasWidthValue = () => !! widthValue; + const resetAll = () => { resetHeightValue(); + resetWidthValue(); resetPaddingValue(); resetMarginValue(); resetGapValue(); @@ -179,6 +195,23 @@ export default function DimensionsPanel( { name } ) { /> ) } + { showWidthControl && ( + + + + ) } { showPaddingControl && ( assertEquals( $all, $theme_json->get_stylesheet() );