/** * External dependencies */ import type { KeyboardEvent, ForwardedRef, SyntheticEvent } from 'react'; import classnames from 'classnames'; /** * WordPress dependencies */ import deprecated from '@wordpress/deprecated'; import { forwardRef, useMemo, useRef, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ import type { WordPressComponentProps } from '../context'; import { ValueInput } from './styles/unit-control-styles'; import UnitSelectControl from './unit-select-control'; import { CSS_UNITS, getParsedQuantityAndUnit, getUnitsWithCurrentUnit, getValidParsedQuantityAndUnit, } from './utils'; import { useControlledState } from '../utils/hooks'; import { escapeRegExp } from '../utils/strings'; import type { UnitControlProps, UnitControlOnChangeCallback } from './types'; import { useDeprecated36pxDefaultSizeProp } from '../utils/use-deprecated-props'; function UnforwardedUnitControl( unitControlProps: WordPressComponentProps< UnitControlProps, 'input', false >, forwardedRef: ForwardedRef< any > ) { const { __unstableStateReducer, autoComplete = 'off', // @ts-expect-error Ensure that children is omitted from restProps children, className, disabled = false, disableUnits = false, isPressEnterToChange = false, isResetValueOnUnitChange = false, isUnitSelectTabbable = true, label, onChange: onChangeProp, onUnitChange, size = 'default', unit: unitProp, units: unitsProp = CSS_UNITS, value: valueProp, onFocus: onFocusProp, ...props } = useDeprecated36pxDefaultSizeProp( unitControlProps, 'wp.components.UnitControl', '6.4' ); if ( 'unit' in unitControlProps ) { deprecated( 'UnitControl unit prop', { since: '5.6', hint: 'The unit should be provided within the `value` prop.', version: '6.2', } ); } // The `value` prop, in theory, should not be `null`, but the following line // ensures it fallback to `undefined` in case a consumer of `UnitControl` // still passes `null` as a `value`. const nonNullValueProp = valueProp ?? undefined; const [ units, reFirstCharacterOfUnits ] = useMemo( () => { const list = getUnitsWithCurrentUnit( nonNullValueProp, unitProp, unitsProp ); const [ { value: firstUnitValue = '' } = {}, ...rest ] = list; const firstCharacters = rest.reduce( ( carry, { value } ) => { const first = escapeRegExp( value?.substring( 0, 1 ) || '' ); return carry.includes( first ) ? carry : `${ carry }|${ first }`; }, escapeRegExp( firstUnitValue.substring( 0, 1 ) ) ); return [ list, new RegExp( `^(?:${ firstCharacters })$`, 'i' ) ]; }, [ nonNullValueProp, unitProp, unitsProp ] ); const [ parsedQuantity, parsedUnit ] = getParsedQuantityAndUnit( nonNullValueProp, unitProp, units ); const [ unit, setUnit ] = useControlledState< string | undefined >( units.length === 1 ? units[ 0 ].value : unitProp, { initial: parsedUnit, fallback: '', } ); useEffect( () => { if ( parsedUnit !== undefined ) { setUnit( parsedUnit ); } }, [ parsedUnit, setUnit ] ); const classes = classnames( 'components-unit-control', // This class is added for legacy purposes to maintain it on the outer // wrapper. See: https://github.com/WordPress/gutenberg/pull/45139 'components-unit-control-wrapper', className ); const handleOnQuantityChange = ( nextQuantityValue: number | string | undefined, changeProps: { event: SyntheticEvent; } ) => { if ( nextQuantityValue === '' || typeof nextQuantityValue === 'undefined' || nextQuantityValue === null ) { onChangeProp?.( '', changeProps ); return; } /* * Customizing the onChange callback. * This allows as to broadcast a combined value+unit to onChange. */ const onChangeValue = getValidParsedQuantityAndUnit( nextQuantityValue, units, parsedQuantity, unit ).join( '' ); onChangeProp?.( onChangeValue, changeProps ); }; const handleOnUnitChange: UnitControlOnChangeCallback = ( nextUnitValue, changeProps ) => { const { data } = changeProps; let nextValue = `${ parsedQuantity ?? '' }${ nextUnitValue }`; if ( isResetValueOnUnitChange && data?.default !== undefined ) { nextValue = `${ data.default }${ nextUnitValue }`; } onChangeProp?.( nextValue, changeProps ); onUnitChange?.( nextUnitValue, changeProps ); setUnit( nextUnitValue ); }; let handleOnKeyDown; if ( ! disableUnits && isUnitSelectTabbable && units.length ) { handleOnKeyDown = ( event: KeyboardEvent< HTMLInputElement > ) => { props.onKeyDown?.( event ); // Unless the meta key was pressed (to avoid interfering with // shortcuts, e.g. pastes), moves focus to the unit select if a key // matches the first character of a unit. if ( ! event.metaKey && reFirstCharacterOfUnits.test( event.key ) ) refInputSuffix.current?.focus(); }; } const refInputSuffix = useRef< HTMLSelectElement >( null ); const inputSuffix = ! disableUnits ? ( ) : null; let step = props.step; /* * If no step prop has been passed, lookup the active unit and * try to get step from `units`, or default to a value of `1` */ if ( ! step && units ) { const activeUnit = units.find( ( option ) => option.value === unit ); step = activeUnit?.step ?? 1; } return ( ); } /** * `UnitControl` allows the user to set a numeric quantity as well as a unit (e.g. `px`). * * * ```jsx * import { __experimentalUnitControl as UnitControl } from '@wordpress/components'; * import { useState } from '@wordpress/element'; * * const Example = () => { * const [ value, setValue ] = useState( '10px' ); * * return ; * }; * ``` */ export const UnitControl = forwardRef( UnforwardedUnitControl ); export { parseQuantityAndUnitFromRawValue, useCustomUnits } from './utils'; export default UnitControl;