diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 249d4f3f65b936..6a7b2545c2e68c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -40,6 +40,7 @@ - Upgraded `@ariakit/react` (v0.4.15) and `@ariakit/test` (v0.4.7) ([#67404](https://github.com/WordPress/gutenberg/pull/67404)). - `ToolbarButton`: Set size to "compact" ([#67440](https://github.com/WordPress/gutenberg/pull/67440)). - `SlotFill`: remove manual rerenders from the portal `Fill` component ([#67471](https://github.com/WordPress/gutenberg/pull/67471)). +- `BoxControl`: Refactor internals to unify the different combinations of sides ([#67626](https://github.com/WordPress/gutenberg/pull/67626)). ### Bug Fixes diff --git a/packages/components/src/box-control/all-input-control.tsx b/packages/components/src/box-control/all-input-control.tsx deleted file mode 100644 index 0cfaea21915f6d..00000000000000 --- a/packages/components/src/box-control/all-input-control.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * WordPress dependencies - */ -import { useInstanceId } from '@wordpress/compose'; -/** - * Internal dependencies - */ -import type { UnitControlProps } from '../unit-control/types'; -import { - FlexedRangeControl, - StyledUnitControl, -} from './styles/box-control-styles'; -import type { BoxControlInputControlProps } from './types'; -import { parseQuantityAndUnitFromRawValue } from '../unit-control'; -import { - LABELS, - applyValueToSides, - getAllValue, - isValuesMixed, - isValuesDefined, - CUSTOM_VALUE_SETTINGS, -} from './utils'; - -const noop = () => {}; - -export default function AllInputControl( { - __next40pxDefaultSize, - onChange = noop, - onFocus = noop, - values, - sides, - selectedUnits, - setSelectedUnits, - ...props -}: BoxControlInputControlProps ) { - const inputId = useInstanceId( AllInputControl, 'box-control-input-all' ); - - const allValue = getAllValue( values, selectedUnits, sides ); - const hasValues = isValuesDefined( values ); - const isMixed = hasValues && isValuesMixed( values, selectedUnits, sides ); - const allPlaceholder = isMixed ? LABELS.mixed : undefined; - - const [ parsedQuantity, parsedUnit ] = - parseQuantityAndUnitFromRawValue( allValue ); - - const handleOnFocus: React.FocusEventHandler< HTMLInputElement > = ( - event - ) => { - onFocus( event, { side: 'all' } ); - }; - - const onValueChange = ( next?: string ) => { - const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) ); - const nextValue = isNumeric ? next : undefined; - const nextValues = applyValueToSides( values, nextValue, sides ); - - onChange( nextValues ); - }; - - const sliderOnChange = ( next?: number ) => { - onValueChange( - next !== undefined ? [ next, parsedUnit ].join( '' ) : undefined - ); - }; - - // Set selected unit so it can be used as fallback by unlinked controls - // when individual sides do not have a value containing a unit. - const handleOnUnitChange: UnitControlProps[ 'onUnitChange' ] = ( unit ) => { - const newUnits = applyValueToSides( selectedUnits, unit, sides ); - setSelectedUnits( newUnits ); - }; - - return ( - <> - - - - - ); -} diff --git a/packages/components/src/box-control/axial-input-controls.tsx b/packages/components/src/box-control/axial-input-controls.tsx deleted file mode 100644 index f8cbc5635c9b55..00000000000000 --- a/packages/components/src/box-control/axial-input-controls.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * WordPress dependencies - */ -import { useInstanceId } from '@wordpress/compose'; -/** - * Internal dependencies - */ -import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; -import Tooltip from '../tooltip'; -import { CUSTOM_VALUE_SETTINGS, LABELS } from './utils'; -import { - FlexedBoxControlIcon, - FlexedRangeControl, - InputWrapper, - StyledUnitControl, -} from './styles/box-control-styles'; -import type { BoxControlInputControlProps } from './types'; - -const groupedSides = [ 'vertical', 'horizontal' ] as const; -type GroupedSide = ( typeof groupedSides )[ number ]; - -export default function AxialInputControls( { - __next40pxDefaultSize, - onChange, - onFocus, - values, - selectedUnits, - setSelectedUnits, - sides, - ...props -}: BoxControlInputControlProps ) { - const generatedId = useInstanceId( - AxialInputControls, - `box-control-input` - ); - - const createHandleOnFocus = - ( side: GroupedSide ) => - ( event: React.FocusEvent< HTMLInputElement > ) => { - if ( ! onFocus ) { - return; - } - onFocus( event, { side } ); - }; - - const handleOnValueChange = ( side: GroupedSide, next?: string ) => { - if ( ! onChange ) { - return; - } - const nextValues = { ...values }; - const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) ); - const nextValue = isNumeric ? next : undefined; - - if ( side === 'vertical' ) { - nextValues.top = nextValue; - nextValues.bottom = nextValue; - } - - if ( side === 'horizontal' ) { - nextValues.left = nextValue; - nextValues.right = nextValue; - } - - onChange( nextValues ); - }; - - const createHandleOnUnitChange = - ( side: GroupedSide ) => ( next?: string ) => { - const newUnits = { ...selectedUnits }; - - if ( side === 'vertical' ) { - newUnits.top = next; - newUnits.bottom = next; - } - - if ( side === 'horizontal' ) { - newUnits.left = next; - newUnits.right = next; - } - - setSelectedUnits( newUnits ); - }; - - // Filter sides if custom configuration provided, maintaining default order. - const filteredSides = sides?.length - ? groupedSides.filter( ( side ) => sides.includes( side ) ) - : groupedSides; - - return ( - <> - { filteredSides.map( ( side ) => { - const [ parsedQuantity, parsedUnit ] = - parseQuantityAndUnitFromRawValue( - side === 'vertical' ? values.top : values.left - ); - const selectedUnit = - side === 'vertical' - ? selectedUnits.top - : selectedUnits.left; - - const inputId = [ generatedId, side ].join( '-' ); - - return ( - - - - - handleOnValueChange( side, newValue ) - } - onUnitChange={ createHandleOnUnitChange( - side - ) } - onFocus={ createHandleOnFocus( side ) } - label={ LABELS[ side ] } - hideLabelFromVision - key={ side } - /> - - - handleOnValueChange( - side, - newValue !== undefined - ? [ - newValue, - selectedUnit ?? parsedUnit, - ].join( '' ) - : undefined - ) - } - min={ 0 } - max={ - CUSTOM_VALUE_SETTINGS[ selectedUnit ?? 'px' ] - ?.max ?? 10 - } - step={ - CUSTOM_VALUE_SETTINGS[ selectedUnit ?? 'px' ] - ?.step ?? 0.1 - } - value={ parsedQuantity ?? 0 } - withInputField={ false } - /> - - ); - } ) } - - ); -} diff --git a/packages/components/src/box-control/index.tsx b/packages/components/src/box-control/index.tsx index 46cf2cd93a9444..279dfa55eafe38 100644 --- a/packages/components/src/box-control/index.tsx +++ b/packages/components/src/box-control/index.tsx @@ -9,13 +9,10 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { BaseControl } from '../base-control'; -import AllInputControl from './all-input-control'; -import InputControls from './input-controls'; -import AxialInputControls from './axial-input-controls'; +import InputControl from './input-control'; import LinkedButton from './linked-button'; import { Grid } from '../grid'; import { - FlexedBoxControlIcon, InputWrapper, ResetButton, LinkedButtonWrapper, @@ -24,8 +21,9 @@ import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; import { DEFAULT_VALUES, getInitialSide, - isValuesMixed, + isValueMixed, isValuesDefined, + getAllowedSides, } from './utils'; import { useControlledState } from '../utils/hooks'; import type { @@ -97,7 +95,7 @@ function BoxControl( { const [ isDirty, setIsDirty ] = useState( hasInitialValue ); const [ isLinked, setIsLinked ] = useState( - ! hasInitialValue || ! isValuesMixed( inputValues ) || hasOneSide + ! hasInitialValue || ! isValueMixed( inputValues ) || hasOneSide ); const [ side, setSide ] = useState< BoxControlIconProps[ 'side' ] >( @@ -162,6 +160,7 @@ function BoxControl( { __next40pxDefaultSize, size: undefined, } ); + const sidesToRender = getAllowedSides( sides ); return ( { isLinked && ( - - + ) } { ! hasOneSide && ( @@ -189,12 +187,24 @@ function BoxControl( { ) } - { ! isLinked && splitOnAxis && ( - - ) } - { ! isLinked && ! splitOnAxis && ( - - ) } + { ! isLinked && + splitOnAxis && + [ 'vertical', 'horizontal' ].map( ( axis ) => ( + + ) ) } + { ! isLinked && + ! splitOnAxis && + Array.from( sidesToRender ).map( ( axis ) => ( + + ) ) } { allowReset && ( {}; + +function getSidesToModify( + side: BoxControlInputControlProps[ 'side' ], + sides: BoxControlInputControlProps[ 'sides' ], + isAlt?: boolean +) { + const allowedSides = getAllowedSides( sides ); + + let modifiedSides: ( keyof BoxControlValue )[] = []; + switch ( side ) { + case 'all': + modifiedSides = [ 'top', 'bottom', 'left', 'right' ]; + break; + case 'horizontal': + modifiedSides = [ 'left', 'right' ]; + break; + case 'vertical': + modifiedSides = [ 'top', 'bottom' ]; + break; + default: + modifiedSides = [ side ]; + } + + if ( isAlt ) { + switch ( side ) { + case 'top': + modifiedSides.push( 'bottom' ); + break; + case 'bottom': + modifiedSides.push( 'top' ); + break; + case 'left': + modifiedSides.push( 'left' ); + break; + case 'right': + modifiedSides.push( 'right' ); + break; + } + } + + return modifiedSides.filter( ( s ) => allowedSides.has( s ) ); +} + +export default function BoxInputControl( { + __next40pxDefaultSize, + onChange = noop, + onFocus = noop, + values, + selectedUnits, + setSelectedUnits, + sides, + side, + ...props +}: BoxControlInputControlProps ) { + const defaultValuesToModify = getSidesToModify( side, sides ); + + const handleOnFocus = ( event: React.FocusEvent< HTMLInputElement > ) => { + onFocus( event, { side } ); + }; + + const handleOnChange = ( nextValues: BoxControlValue ) => { + onChange( nextValues ); + }; + + const handleOnValueChange = ( + next?: string, + extra?: { event: React.SyntheticEvent< Element, Event > } + ) => { + const nextValues = { ...values }; + const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) ); + const nextValue = isNumeric ? next : undefined; + const modifiedSides = getSidesToModify( + side, + sides, + /** + * Supports changing pair sides. For example, holding the ALT key + * when changing the TOP will also update BOTTOM. + */ + // @ts-expect-error - TODO: event.altKey is only present when the change event was + // triggered by a keyboard event. Should this feature be implemented differently so + // it also works with drag events? + !! extra?.event.altKey + ); + + modifiedSides.forEach( ( modifiedSide ) => { + nextValues[ modifiedSide ] = nextValue; + } ); + + handleOnChange( nextValues ); + }; + + const handleOnUnitChange = ( next?: string ) => { + const newUnits = { ...selectedUnits }; + defaultValuesToModify.forEach( ( modifiedSide ) => { + newUnits[ modifiedSide ] = next; + } ); + setSelectedUnits( newUnits ); + }; + + const mergedValue = getMergedValue( values, defaultValuesToModify ); + const hasValues = isValuesDefined( values ); + const isMixed = + hasValues && + defaultValuesToModify.length > 1 && + isValueMixed( values, defaultValuesToModify ); + const [ parsedQuantity, parsedUnit ] = + parseQuantityAndUnitFromRawValue( mergedValue ); + const computedUnit = hasValues + ? parsedUnit + : selectedUnits[ defaultValuesToModify[ 0 ] ]; + const generatedId = useInstanceId( BoxInputControl, 'box-control-input' ); + const inputId = [ generatedId, side ].join( '-' ); + const isMixedUnit = + defaultValuesToModify.length > 1 && + mergedValue === undefined && + defaultValuesToModify.some( + ( s ) => selectedUnits[ s ] !== computedUnit + ); + const usedValue = + mergedValue === undefined && computedUnit ? computedUnit : mergedValue; + const mixedPlaceholder = isMixed || isMixedUnit ? __( 'Mixed' ) : undefined; + + return ( + + + + + + + { + handleOnValueChange( + newValue !== undefined + ? [ newValue, computedUnit ].join( '' ) + : undefined + ); + } } + min={ 0 } + max={ CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ]?.max ?? 10 } + step={ + CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ]?.step ?? 0.1 + } + value={ parsedQuantity ?? 0 } + withInputField={ false } + /> + + ); +} diff --git a/packages/components/src/box-control/input-controls.tsx b/packages/components/src/box-control/input-controls.tsx deleted file mode 100644 index 8f4518d717dbed..00000000000000 --- a/packages/components/src/box-control/input-controls.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/** - * WordPress dependencies - */ -import { useInstanceId } from '@wordpress/compose'; -/** - * Internal dependencies - */ -import Tooltip from '../tooltip'; -import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; -import { ALL_SIDES, CUSTOM_VALUE_SETTINGS, LABELS } from './utils'; -import { - FlexedBoxControlIcon, - FlexedRangeControl, - InputWrapper, - StyledUnitControl, -} from './styles/box-control-styles'; -import type { BoxControlInputControlProps, BoxControlValue } from './types'; - -const noop = () => {}; - -export default function BoxInputControls( { - __next40pxDefaultSize, - onChange = noop, - onFocus = noop, - values, - selectedUnits, - setSelectedUnits, - sides, - ...props -}: BoxControlInputControlProps ) { - const generatedId = useInstanceId( BoxInputControls, 'box-control-input' ); - - const createHandleOnFocus = - ( side: keyof BoxControlValue ) => - ( event: React.FocusEvent< HTMLInputElement > ) => { - onFocus( event, { side } ); - }; - - const handleOnChange = ( nextValues: BoxControlValue ) => { - onChange( nextValues ); - }; - - const handleOnValueChange = ( - side: keyof BoxControlValue, - next?: string, - extra?: { event: React.SyntheticEvent< Element, Event > } - ) => { - const nextValues = { ...values }; - const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) ); - const nextValue = isNumeric ? next : undefined; - - nextValues[ side ] = nextValue; - - /** - * Supports changing pair sides. For example, holding the ALT key - * when changing the TOP will also update BOTTOM. - */ - // @ts-expect-error - TODO: event.altKey is only present when the change event was - // triggered by a keyboard event. Should this feature be implemented differently so - // it also works with drag events? - if ( extra?.event.altKey ) { - switch ( side ) { - case 'top': - nextValues.bottom = nextValue; - break; - case 'bottom': - nextValues.top = nextValue; - break; - case 'left': - nextValues.right = nextValue; - break; - case 'right': - nextValues.left = nextValue; - break; - } - } - - handleOnChange( nextValues ); - }; - - const createHandleOnUnitChange = - ( side: keyof BoxControlValue ) => ( next?: string ) => { - const newUnits = { ...selectedUnits }; - newUnits[ side ] = next; - setSelectedUnits( newUnits ); - }; - - // Filter sides if custom configuration provided, maintaining default order. - const filteredSides = sides?.length - ? ALL_SIDES.filter( ( side ) => sides.includes( side ) ) - : ALL_SIDES; - - return ( - <> - { filteredSides.map( ( side ) => { - const [ parsedQuantity, parsedUnit ] = - parseQuantityAndUnitFromRawValue( values[ side ] ); - - const computedUnit = values[ side ] - ? parsedUnit - : selectedUnits[ side ]; - - const inputId = [ generatedId, side ].join( '-' ); - - return ( - - - - - handleOnValueChange( - side, - nextValue, - extra - ) - } - onUnitChange={ createHandleOnUnitChange( - side - ) } - onFocus={ createHandleOnFocus( side ) } - label={ LABELS[ side ] } - hideLabelFromVision - /> - - - { - handleOnValueChange( - side, - newValue !== undefined - ? [ newValue, computedUnit ].join( '' ) - : undefined - ); - } } - min={ 0 } - max={ - CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ] - ?.max ?? 10 - } - step={ - CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ] - ?.step ?? 0.1 - } - value={ parsedQuantity ?? 0 } - withInputField={ false } - /> - - ); - } ) } - - ); -} diff --git a/packages/components/src/box-control/types.ts b/packages/components/src/box-control/types.ts index 16b69dfe70a64a..73de68d1bd513a 100644 --- a/packages/components/src/box-control/types.ts +++ b/packages/components/src/box-control/types.ts @@ -110,8 +110,16 @@ export type BoxControlInputControlProps = UnitControlPassthroughProps & { ) => void; selectedUnits: BoxControlValue; setSelectedUnits: React.Dispatch< React.SetStateAction< BoxControlValue > >; - sides: BoxControlProps[ 'sides' ]; values: BoxControlValue; + /** + * Collection of sides to allow control of. If omitted or empty, all sides will be available. + */ + sides: BoxControlProps[ 'sides' ]; + /** + * Side represents the current side being rendered by the input. + * It can be a concrete side like: left, right, top, bottom or a combined one like: horizontal, vertical. + */ + side: keyof typeof LABELS; }; export type BoxControlIconProps = { diff --git a/packages/components/src/box-control/utils.ts b/packages/components/src/box-control/utils.ts index 73c7f4a6a46cfb..111451790e35d5 100644 --- a/packages/components/src/box-control/utils.ts +++ b/packages/components/src/box-control/utils.ts @@ -6,12 +6,13 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils'; import type { + BoxControlInputControlProps, BoxControlProps, BoxControlValue, CustomValueUnits, } from './types'; +import deprecated from '@wordpress/deprecated'; export const CUSTOM_VALUE_SETTINGS: CustomValueUnits = { px: { max: 300, step: 1 }, @@ -50,7 +51,6 @@ export const LABELS = { bottom: __( 'Bottom side' ), left: __( 'Left side' ), right: __( 'Right side' ), - mixed: __( 'Mixed' ), vertical: __( 'Top and bottom sides' ), horizontal: __( 'Left and right sides' ), }; @@ -82,56 +82,46 @@ function mode< T >( arr: T[] ) { } /** - * Gets the 'all' input value and unit from values data. + * Gets the merged input value and unit from values data. * * @param values Box values. - * @param selectedUnits Box units. * @param availableSides Available box sides to evaluate. * * @return A value + unit for the 'all' input. */ -export function getAllValue( +export function getMergedValue( values: BoxControlValue = {}, - selectedUnits?: BoxControlValue, availableSides: BoxControlProps[ 'sides' ] = ALL_SIDES ) { const sides = normalizeSides( availableSides ); - const parsedQuantitiesAndUnits = sides.map( ( side ) => - parseQuantityAndUnitFromRawValue( values[ side ] ) - ); - const allParsedQuantities = parsedQuantitiesAndUnits.map( - ( value ) => value[ 0 ] ?? '' - ); - const allParsedUnits = parsedQuantitiesAndUnits.map( - ( value ) => value[ 1 ] - ); - - const commonQuantity = allParsedQuantities.every( - ( v ) => v === allParsedQuantities[ 0 ] - ) - ? allParsedQuantities[ 0 ] - : ''; - - /** - * The typeof === 'number' check is important. On reset actions, the incoming value - * may be null or an empty string. - * - * Also, the value may also be zero (0), which is considered a valid unit value. - * - * typeof === 'number' is more specific for these cases, rather than relying on a - * simple truthy check. - */ - let commonUnit; - if ( typeof commonQuantity === 'number' ) { - commonUnit = mode( allParsedUnits ); - } else { - // Set meaningful unit selection if no commonQuantity and user has previously - // selected units without assigning values while controls were unlinked. - commonUnit = - getAllUnitFallback( selectedUnits ) ?? mode( allParsedUnits ); + if ( + sides.every( + ( side: keyof BoxControlValue ) => + values[ side ] === values[ sides[ 0 ] ] + ) + ) { + return values[ sides[ 0 ] ]; } - return [ commonQuantity, commonUnit ].join( '' ); + return undefined; +} + +/** + * Checks if the values are mixed. + * + * @param values Box values. + * @param availableSides Available box sides to evaluate. + * @return Whether the values are mixed. + */ +export function isValueMixed( + values: BoxControlValue = {}, + availableSides: BoxControlProps[ 'sides' ] = ALL_SIDES +) { + const sides = normalizeSides( availableSides ); + return sides.some( + ( side: keyof BoxControlValue ) => + values[ side ] !== values[ sides[ 0 ] ] + ); } /** @@ -150,26 +140,6 @@ export function getAllUnitFallback( selectedUnits?: BoxControlValue ) { return mode( filteredUnits ); } -/** - * Checks to determine if values are mixed. - * - * @param values Box values. - * @param selectedUnits Box units. - * @param sides Available box sides to evaluate. - * - * @return Whether values are mixed. - */ -export function isValuesMixed( - values: BoxControlValue = {}, - selectedUnits?: BoxControlValue, - sides: BoxControlProps[ 'sides' ] = ALL_SIDES -) { - const allValue = getAllValue( values, selectedUnits, sides ); - const isMixed = isNaN( parseFloat( allValue ) ); - - return isMixed; -} - /** * Checks to determine if values are defined. * @@ -239,6 +209,8 @@ export function normalizeSides( sides: BoxControlProps[ 'sides' ] ) { * Applies a value to an object representing top, right, bottom and left sides * while taking into account any custom side configuration. * + * @deprecated + * * @param currentValues The current values for each side. * @param newValue The value to apply to the sides object. * @param sides Array defining valid sides. @@ -250,6 +222,10 @@ export function applyValueToSides( newValue?: string, sides?: BoxControlProps[ 'sides' ] ): BoxControlValue { + deprecated( 'applyValueToSides', { + since: '6.8', + version: '7.0', + } ); const newValues = { ...currentValues }; if ( sides?.length ) { @@ -270,3 +246,29 @@ export function applyValueToSides( return newValues; } + +/** + * Return the allowed sides based on the sides configuration. + * + * @param sides Sides configuration. + * @return Allowed sides. + */ +export function getAllowedSides( + sides: BoxControlInputControlProps[ 'sides' ] +) { + const allowedSides: Set< keyof BoxControlValue > = new Set( + ! sides ? ALL_SIDES : [] + ); + sides?.forEach( ( allowedSide ) => { + if ( allowedSide === 'vertical' ) { + allowedSides.add( 'top' ); + allowedSides.add( 'bottom' ); + } else if ( allowedSide === 'horizontal' ) { + allowedSides.add( 'right' ); + allowedSides.add( 'left' ); + } else { + allowedSides.add( allowedSide ); + } + } ); + return allowedSides; +}