fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,149 @@
# UnitControl
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>
`UnitControl` allows the user to set a numeric quantity as well as a unit (e.g. `px`).
## Usage
```jsx
import { useState } from 'react';
import { __experimentalUnitControl as UnitControl } from '@wordpress/components';
const Example = () => {
const [ value, setValue ] = useState( '10px' );
return <UnitControl onChange={ setValue } value={ value } />;
};
```
## Props
### `disableUnits`: `boolean`
If true, the unit `<select>` is hidden.
- Required: No
- Default: `false`
### `isPressEnterToChange`: `boolean`
If `true`, the `ENTER` key press is required in order to trigger an `onChange`. If enabled, a change is also triggered when tabbing away (`onBlur`).
- Required: No
- Default: `false`
### `isResetValueOnUnitChange`: `boolean`
If `true`, and the selected unit provides a `default` value, this value is set when changing units.
- Required: No
- Default: `false`
### `isUnitSelectTabbable`: `boolean`
Determines if the unit `<select>` is tabbable.
- Required: No
- Default: `true`
### `label`: `string`
If this property is added, a label will be generated using label property as the content.
- Required: No
### `labelPosition`: `string`
The position of the label (`top`, `side`, `bottom`, or `edge`).
- Required: No
### `onBlur`: `FocusEventHandler< HTMLInputElement | HTMLSelectElement >`
Callback invoked when either the quantity or unit inputs fire the `blur` event.
- Required: No
### `onFocus`: `FocusEventHandler< HTMLInputElement | HTMLSelectElement >`
Callback invoked when either the quantity or unit inputs fire the `focus` event.
- Required: No
### `onChange`: `UnitControlOnChangeCallback`
Callback when the `value` changes.
- Required: No
### `onUnitChange`: `UnitControlOnChangeCallback`
Callback when the `unit` changes.
- Required: No
### `size`: `string`
Adjusts the size of the input.
Sizes include: `default`, `small`
- Required: No
- Default: `default`
### `unit`: `string`
Deprecated: Current unit value.
Instead, provide a unit with a value through the `value` prop.
Example:
```jsx
<UnitControl value="50%" />
```
- Required: No
### `units`: `WPUnitControlUnit[]`
Collection of available units.
- Required: No
Example:
```jsx
import { useState } from 'react';
import { __experimentalUnitControl as UnitControl } from '@wordpress/components';
const Example = () => {
const [ value, setValue ] = useState( '10px' );
const units = [
{ value: 'px', label: 'px', default: 0 },
{ value: '%', label: '%', default: 10 },
{ value: 'em', label: 'em', default: 0 },
];
return (
<UnitControl onChange={ setValue } value={ value } units={ units } />
);
};
```
A `default` value (in the example above, `10` for `%`), if defined, is set as the new `value` when a unit changes. This is helpful in scenarios where changing a unit may cause drastic results, such as changing from `px` to `vh`.
### `value`: `number | string`
Current value. If passed as a string, the current unit will be inferred from this value.
For example, a `value` of `50%` will set the current unit to `%`.
Example:
```jsx
<UnitControl value="50%" />
```
- Required: No

View File

@@ -0,0 +1,206 @@
/**
* External dependencies
*/
import {
Text,
View,
TouchableWithoutFeedback,
Platform,
findNodeHandle,
} from 'react-native';
/**
* Internal dependencies
*/
import RangeCell from '../mobile/bottom-sheet/range-cell';
import StepperCell from '../mobile/bottom-sheet/stepper-cell';
import Picker from '../mobile/picker';
import styles from './style.scss';
import { CSS_UNITS, hasUnits, getAccessibleLabelForUnit } from './utils';
/**
* WordPress dependencies
*/
import { useRef, useCallback, useMemo, memo } from '@wordpress/element';
import { withPreferredColorScheme } from '@wordpress/compose';
import { __, sprintf } from '@wordpress/i18n';
function UnitControl( {
currentInput,
label,
value,
onChange,
onUnitChange,
initialPosition,
min,
max,
separatorType,
units = CSS_UNITS,
unit,
getStylesFromColorScheme,
...props
} ) {
const pickerRef = useRef();
const anchorNodeRef = useRef();
const onPickerPresent = useCallback( () => {
if ( pickerRef?.current ) {
pickerRef.current.presentPicker();
}
// Disable reason: this should be fixed by the native team.
// It would be great if this could be done in the context of
// https://github.com/WordPress/gutenberg/pull/39218
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ pickerRef?.current ] );
const currentInputValue = currentInput === null ? value : currentInput;
const initialControlValue = isFinite( currentInputValue )
? currentInputValue
: initialPosition;
const unitButtonTextStyle = getStylesFromColorScheme(
styles.unitButtonText,
styles.unitButtonTextDark
);
/* translators: accessibility text. Inform about current unit value. %s: Current unit value. */
const accessibilityLabel = sprintf( __( 'Current unit is %s' ), unit );
const accessibilityHint =
Platform.OS === 'ios'
? __( 'Double tap to open Action Sheet with available options' )
: __( 'Double tap to open Bottom Sheet with available options' );
const renderUnitButton = useMemo( () => {
const unitButton = (
<View style={ styles.unitButton }>
<Text style={ unitButtonTextStyle }>{ unit }</Text>
</View>
);
if ( hasUnits( units ) && units?.length > 1 ) {
return (
<TouchableWithoutFeedback
onPress={ onPickerPresent }
accessibilityLabel={ accessibilityLabel }
accessibilityRole="button"
accessibilityHint={ accessibilityHint }
>
{ unitButton }
</TouchableWithoutFeedback>
);
}
return unitButton;
}, [
onPickerPresent,
accessibilityLabel,
accessibilityHint,
unitButtonTextStyle,
unit,
units,
] );
const getAnchor = useCallback(
() =>
anchorNodeRef?.current
? findNodeHandle( anchorNodeRef?.current )
: undefined,
// Disable reason: this should be fixed by the native team.
// It would be great if this could be done in the context of
// https://github.com/WordPress/gutenberg/pull/39218
// eslint-disable-next-line react-hooks/exhaustive-deps
[ anchorNodeRef?.current ]
);
const getDecimal = ( step ) => {
// Return the decimal offset based on the step size.
// if step size is 0.1 we expect the offset to be 1.
// for example 12 + 0.1 we would expect the see 12.1 (not 12.10 or 12 );
// steps are defined in the CSS_UNITS and they vary from unit to unit.
const stepToString = step;
const splitStep = stepToString.toString().split( '.' );
return splitStep[ 1 ] ? splitStep[ 1 ].length : 0;
};
const renderUnitPicker = useCallback( () => {
// Keeping for legacy reasons, although `false` should not be a valid
// value for the `units` prop anymore.
if ( units === false ) {
return null;
}
return (
<View style={ styles.unitMenu } ref={ anchorNodeRef }>
{ renderUnitButton }
{ hasUnits( units ) && units?.length > 1 ? (
<Picker
ref={ pickerRef }
options={ units }
onChange={ onUnitChange }
hideCancelButton
leftAlign
getAnchor={ getAnchor }
/>
) : null }
</View>
);
}, [ pickerRef, units, onUnitChange, getAnchor, renderUnitButton ] );
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;
}
const decimalNum = getDecimal( step );
return (
<>
{ unit !== '%' ? (
<StepperCell
label={ label }
max={ max }
min={ min }
onChange={ onChange }
separatorType={ separatorType }
value={ value }
step={ step }
defaultValue={ initialControlValue }
shouldDisplayTextInput
decimalNum={ decimalNum }
openUnitPicker={ onPickerPresent }
unitLabel={ getAccessibleLabelForUnit( unit ) }
{ ...props }
>
{ renderUnitPicker() }
</StepperCell>
) : (
<RangeCell
label={ label }
onChange={ onChange }
minimumValue={ min }
maximumValue={ max }
value={ value }
step={ step }
unit={ unit }
defaultValue={ initialControlValue }
separatorType={ separatorType }
decimalNum={ decimalNum }
openUnitPicker={ onPickerPresent }
unitLabel={ getAccessibleLabelForUnit( unit ) }
{ ...props }
>
{ renderUnitPicker() }
</RangeCell>
) }
</>
);
}
export { useCustomUnits } from './utils';
export default memo( withPreferredColorScheme( UnitControl ) );

View File

@@ -0,0 +1,255 @@
/**
* 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 ? (
<UnitSelectControl
ref={ refInputSuffix }
aria-label={ __( 'Select unit' ) }
disabled={ disabled }
isUnitSelectTabbable={ isUnitSelectTabbable }
onChange={ handleOnUnitChange }
size={
[ 'small', 'compact' ].includes( size ) ||
( size === 'default' && ! props.__next40pxDefaultSize )
? 'small'
: 'default'
}
unit={ unit }
units={ units }
onFocus={ onFocusProp }
onBlur={ unitControlProps.onBlur }
/>
) : 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 (
<ValueInput
{ ...props }
autoComplete={ autoComplete }
className={ classes }
disabled={ disabled }
spinControls="none"
isPressEnterToChange={ isPressEnterToChange }
label={ label }
onKeyDown={ handleOnKeyDown }
onChange={ handleOnQuantityChange }
ref={ forwardedRef }
size={ size }
suffix={ inputSuffix }
type={ isPressEnterToChange ? 'text' : 'number' }
value={ parsedQuantity ?? '' }
step={ step }
onFocus={ onFocusProp }
__unstableStateReducer={ __unstableStateReducer }
/>
);
}
/**
* `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 <UnitControl onChange={ setValue } value={ value } />;
* };
* ```
*/
export const UnitControl = forwardRef( UnforwardedUnitControl );
export { parseQuantityAndUnitFromRawValue, useCustomUnits } from './utils';
export default UnitControl;

View File

@@ -0,0 +1,147 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { UnitControl } from '../';
import { CSS_UNITS } from '../utils';
const meta: Meta< typeof UnitControl > = {
component: UnitControl,
title: 'Components (Experimental)/UnitControl',
argTypes: {
__unstableInputWidth: { control: { type: 'text' } },
__unstableStateReducer: { control: { type: null } },
onChange: { control: { type: null } },
onUnitChange: { control: { type: null } },
prefix: { control: { type: 'text' } },
value: { control: { type: null } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: {
expanded: true,
},
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const DefaultTemplate: StoryFn< typeof UnitControl > = ( {
onChange,
...args
} ) => {
const [ value, setValue ] = useState< string | undefined >( '10px' );
return (
<UnitControl
{ ...args }
value={ value }
onChange={ ( v, extra ) => {
setValue( v );
onChange?.( v, extra );
} }
/>
);
};
export const Default: StoryFn< typeof UnitControl > = DefaultTemplate.bind(
{}
);
Default.args = {
label: 'Label',
};
/**
* If the `isPressEnterToChange` prop is set to `true`, the `onChange` callback
* will not fire while a new value is typed in the input field (you can verify this
* behavior by inspecting the console's output).
*/
export const PressEnterToChange: StoryFn< typeof UnitControl > =
DefaultTemplate.bind( {} );
PressEnterToChange.args = {
...Default.args,
isPressEnterToChange: true,
};
/**
* Most of `NumberControl`'s props can be passed to `UnitControl`, and they will
* affect its numeric input field.
*/
export const TweakingTheNumberInput: StoryFn< typeof UnitControl > =
DefaultTemplate.bind( {} );
TweakingTheNumberInput.args = {
...Default.args,
min: 0,
max: 100,
step: 'any',
label: 'Custom label',
};
/**
* When only one unit is available, the unit selection dropdown becomes static text.
*/
export const WithSingleUnit: StoryFn< typeof UnitControl > =
DefaultTemplate.bind( {} );
WithSingleUnit.args = {
...Default.args,
units: CSS_UNITS.slice( 0, 1 ),
};
/**
* It is possible to pass a custom list of units. Every time the unit changes,
* if the `isResetValueOnUnitChange` is set to `true`, the input's quantity is
* reset to the new unit's default value.
*/
export const WithCustomUnits: StoryFn< typeof UnitControl > = ( {
onChange,
...args
} ) => {
const [ value, setValue ] = useState< string | undefined >( '80km' );
return (
<UnitControl
{ ...args }
value={ value }
onChange={ ( v, extra ) => {
setValue( v );
onChange?.( v, extra );
} }
/>
);
};
WithCustomUnits.args = {
...Default.args,
isResetValueOnUnitChange: true,
min: 0,
units: [
{
value: 'km',
label: 'km',
default: 1,
},
{
value: 'mi',
label: 'mi',
default: 1,
},
{
value: 'm',
label: 'm',
default: 1000,
},
{
value: 'yd',
label: 'yd',
default: 1760,
},
],
};

View File

@@ -0,0 +1,19 @@
.unitButtonText {
color: $blue-wordpress;
}
.unitButtonTextDark {
color: $blue-30;
}
.unitButton {
padding-right: $grid-unit;
padding-left: $grid-unit;
align-items: flex-end;
justify-content: flex-end;
}
.unitMenu {
justify-content: center;
align-items: center;
}

View File

@@ -0,0 +1,148 @@
/**
* External dependencies
*/
import { css } from '@emotion/react';
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import { COLORS, CONFIG, rtl } from '../../utils';
import NumberControl from '../../number-control';
import { BackdropUI } from '../../input-control/styles/input-control-styles';
import type { SelectSize } from '../types';
import { space } from '../../utils/space';
// Using `selectSize` instead of `size` to avoid a type conflict with the
// `size` HTML attribute of the `select` element.
type SelectProps = {
selectSize: SelectSize;
};
// TODO: Resolve need to use &&& to increase specificity
// https://github.com/WordPress/gutenberg/issues/18483
export const ValueInput = styled( NumberControl )`
&&& {
input {
display: block;
width: 100%;
}
${ BackdropUI } {
transition: box-shadow 0.1s linear;
}
}
`;
const baseUnitLabelStyles = ( { selectSize }: SelectProps ) => {
const sizes = {
small: css`
box-sizing: border-box;
padding: 2px 1px;
width: 20px;
color: ${ COLORS.gray[ 800 ] };
font-size: 8px;
line-height: 1;
letter-spacing: -0.5px;
text-transform: uppercase;
text-align-last: center;
`,
default: css`
box-sizing: border-box;
min-width: 24px;
max-width: 48px;
height: 24px;
margin-inline-end: ${ space( 2 ) };
padding: ${ space( 1 ) };
color: ${ COLORS.theme.accent };
font-size: 13px;
line-height: 1;
text-align-last: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
};
return sizes[ selectSize ];
};
export const UnitLabel = styled.div< SelectProps >`
&&& {
pointer-events: none;
${ baseUnitLabelStyles };
color: ${ COLORS.gray[ 900 ] };
}
`;
const unitSelectSizes = ( { selectSize = 'default' }: SelectProps ) => {
const sizes = {
small: css`
height: 100%;
border: 1px solid transparent;
transition:
box-shadow 0.1s linear,
border 0.1s linear;
${ rtl( { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 } )() }
&:not(:disabled):hover {
background-color: ${ COLORS.gray[ 100 ] };
}
&:focus {
border: 1px solid ${ COLORS.ui.borderFocus };
box-shadow: inset 0 0 0
${ CONFIG.borderWidth + ' ' + COLORS.ui.borderFocus };
outline-offset: 0;
outline: 2px solid transparent;
z-index: 1;
}
`,
default: css`
display: flex;
justify-content: center;
align-items: center;
&:hover {
color: ${ COLORS.ui.borderFocus };
box-shadow: inset 0 0 0
${ CONFIG.borderWidth + ' ' + COLORS.ui.borderFocus };
outline: ${ CONFIG.borderWidth } solid transparent; // For High Contrast Mode
}
&:focus {
box-shadow: 0 0 0
${ CONFIG.borderWidthFocus + ' ' + COLORS.ui.borderFocus };
outline: ${ CONFIG.borderWidthFocus } solid transparent; // For High Contrast Mode
}
`,
};
return sizes[ selectSize ];
};
export const UnitSelect = styled.select< SelectProps >`
// The &&& counteracts <select> styles in WP forms.css
&&& {
appearance: none;
background: transparent;
border-radius: 2px;
border: none;
display: block;
outline: none;
/* Removing margin ensures focus styles neatly overlay the wrapper. */
margin: 0;
min-height: auto;
font-family: inherit;
&:not( :disabled ) {
cursor: pointer;
}
${ baseUnitLabelStyles };
${ unitSelectSizes };
}
`;

View File

@@ -0,0 +1,645 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import UnitControl from '..';
import { CSS_UNITS, parseQuantityAndUnitFromRawValue } from '../utils';
const getInput = ( {
isInputTypeText = false,
}: {
isInputTypeText?: boolean;
} = {} ) =>
screen.getByRole(
isInputTypeText ? 'textbox' : 'spinbutton'
) as HTMLInputElement;
const getSelect = () => screen.getByRole( 'combobox' ) as HTMLSelectElement;
const getSelectOptions = () =>
screen.getAllByRole( 'option' ) as HTMLOptionElement[];
const ControlledSyncUnits = () => {
const [ state, setState ] = useState( {
valueA: '',
valueB: '',
} );
// Keep the unit sync'd between the two `UnitControl` instances.
const onUnitControlChange = (
fieldName: 'valueA' | 'valueB',
newValue?: string | number
) => {
const parsedQuantityAndUnit =
parseQuantityAndUnitFromRawValue( newValue );
const quantity = parsedQuantityAndUnit[ 0 ];
if ( ! Number.isFinite( quantity ) ) {
return;
}
const newUnit = parsedQuantityAndUnit[ 1 ];
const nextState = {
...state,
[ fieldName ]: newValue,
};
Object.entries( state ).forEach( ( [ stateProp, stateValue ] ) => {
const [ stateQuantity, stateUnit ] =
parseQuantityAndUnitFromRawValue( stateValue );
if ( stateProp !== fieldName && stateUnit !== newUnit ) {
nextState[
stateProp as 'valueA' | 'valueB'
] = `${ stateQuantity }${ newUnit }`;
}
} );
setState( nextState );
};
return (
<>
<UnitControl
label="Field A"
value={ state.valueA }
onChange={ ( v ) => onUnitControlChange( 'valueA', v ) }
/>
<UnitControl
label="Field B"
value={ state.valueB }
onChange={ ( v ) => onUnitControlChange( 'valueB', v ) }
/>
</>
);
};
describe( 'UnitControl', () => {
describe( 'Basic rendering', () => {
it( 'should render', () => {
render( <UnitControl /> );
const input = getInput();
const select = getSelect();
expect( input ).toBeInTheDocument();
expect( select ).toBeInTheDocument();
} );
it( 'should render custom className', () => {
const { container: withoutClassName } = render( <UnitControl /> );
const { container: withClassName } = render(
<UnitControl className="hello" />
);
expect(
// eslint-disable-next-line testing-library/no-node-access
withoutClassName.querySelector( '.components-unit-control' )
).not.toHaveClass( 'hello' );
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
withClassName.querySelector( '.components-unit-control' )
).toHaveClass( 'hello' );
} );
it( 'should not render select, if units are disabled', () => {
render( <UnitControl value="3em" units={ [] } /> );
const input = getInput();
// Using `queryByRole` instead of `getSelect` because we need to test
// for this element NOT to be in the document.
const select = screen.queryByRole( 'combobox' );
expect( input ).toBeInTheDocument();
expect( select ).not.toBeInTheDocument();
} );
it( 'should render label if single units', () => {
render( <UnitControl units={ [ { value: '%', label: '%' } ] } /> );
const select = screen.queryByRole( 'combobox' );
const label = screen.getByText( '%' );
expect( select ).not.toBeInTheDocument();
expect( label ).toBeInTheDocument();
} );
} );
describe( 'Value', () => {
it( 'should update value on change', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render( <UnitControl value={ '50px' } onChange={ onChangeSpy } /> );
const input = getInput();
await user.clear( input );
await user.type( input, '62' );
// 3 times:
// - 1: clear
// - 2: type '6'
// - 3: type '62'
expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'62px',
expect.anything()
);
} );
it( 'should increment value on UP press', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render( <UnitControl value={ '50px' } onChange={ onChangeSpy } /> );
const input = getInput();
await user.type( input, '{ArrowUp}' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'51px',
expect.anything()
);
} );
it( 'should increment value on UP + SHIFT press, with step', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render( <UnitControl value={ '50px' } onChange={ onChangeSpy } /> );
const input = getInput();
await user.type( input, '{Shift>}{ArrowUp}{/Shift}' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'60px',
expect.anything()
);
} );
it( 'should decrement value on DOWN press', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render( <UnitControl value={ 50 } onChange={ onChangeSpy } /> );
const input = getInput();
await user.type( input, '{ArrowDown}' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'49px',
expect.anything()
);
} );
it( 'should decrement value on DOWN + SHIFT press, with step', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render( <UnitControl value={ 50 } onChange={ onChangeSpy } /> );
const input = getInput();
await user.type( input, '{Shift>}{ArrowDown}{/Shift}' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'40px',
expect.anything()
);
} );
it( 'should cancel change when ESCAPE key is pressed', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<UnitControl
value={ 50 }
onChange={ onChangeSpy }
isPressEnterToChange
/>
);
// Input type is `text` when the `isPressEnterToChange` prop is passed
const input = getInput( { isInputTypeText: true } );
await user.clear( input );
await user.type( input, '300' );
expect( input.value ).toBe( '300' );
expect( onChangeSpy ).not.toHaveBeenCalled();
await user.keyboard( '{Escape}' );
expect( input.value ).toBe( '50' );
expect( onChangeSpy ).not.toHaveBeenCalled();
} );
it( 'should run onBlur callback when quantity input is blurred', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
const onBlurSpy = jest.fn();
render(
<UnitControl
value={ '33%' }
onChange={ onChangeSpy }
onBlur={ onBlurSpy }
/>
);
const input = getInput();
await user.clear( input );
await user.type( input, '41' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'41%',
expect.anything()
);
// Clicking document.body to trigger a blur event on the input.
await user.click( document.body );
expect( onBlurSpy ).toHaveBeenCalledTimes( 1 );
} );
it( 'should invoke onChange when isPressEnterToChange is true and the input is blurred with an uncommitted value', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<UnitControl
value={ '15px' }
onChange={ onChangeSpy }
isPressEnterToChange
/>
);
// Input type is `text` when the `isPressEnterToChange` prop is passed
const input = getInput( { isInputTypeText: true } );
await user.clear( input );
// Typing the first letter of a unit blurs the input.
await user.type( input, '41v' );
// Called only once because `isPressEnterToChange` is `true`.
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
// The correct expected behavior would be for the `onChangeSpy` callback
// to be called twice, first with `41px` and immediately after with `41vh`,
// but the test environment doesn't seem to change values on `select`
// elements when using the keyboard.
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'41px',
expect.anything()
);
} );
it( 'should update value correctly when typed and blurred when a single unit is passed', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<>
<button>Click me</button>
<UnitControl
units={ [ { value: '%', label: '%' } ] }
onChange={ onChangeSpy }
/>
</>
);
const input = getInput();
await user.type( input, '62' );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'62%',
expect.anything()
);
// Start counting again calls to `onChangeSpy`.
onChangeSpy.mockClear();
// Clicking on the button should cause the `onBlur` callback to fire.
const button = screen.getByRole( 'button' );
await user.click( button );
expect( onChangeSpy ).not.toHaveBeenCalled();
} );
} );
describe( 'Unit', () => {
it( 'should update unit value on change', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
const onUnitChangeSpy = jest.fn();
render(
<UnitControl
value={ '14rem' }
onChange={ onChangeSpy }
onUnitChange={ onUnitChangeSpy }
/>
);
const select = getSelect();
await user.selectOptions( select, [ 'px' ] );
expect( onUnitChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onUnitChangeSpy ).toHaveBeenLastCalledWith(
'px',
expect.anything()
);
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'14px',
expect.anything()
);
} );
it( 'should render customized units, if defined', () => {
const units = [
{ value: 'pt', label: 'pt', default: 0 },
{ value: 'vmax', label: 'vmax', default: 10 },
// Proves that units with regex control characters don't error.
{ value: '+', label: '+', default: 10 },
];
render( <UnitControl units={ units } /> );
const options = getSelectOptions();
expect( options.length ).toBe( 3 );
const [ pt, vmax, plus ] = options;
expect( pt.value ).toBe( 'pt' );
expect( vmax.value ).toBe( 'vmax' );
expect( plus.value ).toBe( '+' );
} );
it( 'should reset value on unit change, if unit has default value', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
const units = [
{ value: 'pt', label: 'pt', default: 25 },
{ value: 'vmax', label: 'vmax', default: 75 },
];
render(
<UnitControl
isResetValueOnUnitChange
units={ units }
onChange={ onChangeSpy }
value={ 50 }
/>
);
const select = getSelect();
await user.selectOptions( select, [ 'vmax' ] );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'75vmax',
expect.anything()
);
await user.selectOptions( select, [ 'pt' ] );
expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'25pt',
expect.anything()
);
} );
it( 'should not reset value on unit change, if disabled', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
const units = [
{ value: 'pt', label: 'pt', default: 25 },
{ value: 'vmax', label: 'vmax', default: 75 },
];
render(
<UnitControl
isResetValueOnUnitChange={ false }
value={ 50 }
units={ units }
onChange={ onChangeSpy }
/>
);
const select = getSelect();
await user.selectOptions( select, [ 'vmax' ] );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'50vmax',
expect.anything()
);
await user.selectOptions( select, [ 'pt' ] );
expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'50pt',
expect.anything()
);
} );
it( 'should set correct unit if single units', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<UnitControl
value={ '50%' }
units={ [ { value: '%', label: '%' } ] }
onChange={ onChangeSpy }
/>
);
const input = getInput();
await user.clear( input );
await user.type( input, '62' );
// 3 times:
// - 1: clear
// - 2: type '6'
// - 3: type '62'
expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'62%',
expect.anything()
);
} );
it( 'should update unit value when a new raw value is passed', async () => {
const user = userEvent.setup();
render( <ControlledSyncUnits /> );
const [ inputA, inputB ] = screen.getAllByRole( 'spinbutton' );
const [ selectA, selectB ] = screen.getAllByRole( 'combobox' );
const [ remOptionA ] = screen.getAllByRole( 'option', {
name: 'rem',
} );
const [ , vwOptionB ] = screen.getAllByRole( 'option', {
name: 'vw',
} );
await user.type( inputA, '55' );
await user.type( inputB, '14' );
await user.selectOptions( selectA, remOptionA );
expect( selectB ).toHaveValue( 'rem' );
expect( selectA ).toHaveValue( 'rem' );
await user.selectOptions( selectB, vwOptionB );
expect( selectA ).toHaveValue( 'vw' );
expect( selectB ).toHaveValue( 'vw' );
} );
it( 'should maintain the chosen non-default unit when value is cleared', async () => {
const user = userEvent.setup();
const units = [
{ value: 'pt', label: 'pt' },
{ value: 'vmax', label: 'vmax' },
];
render( <UnitControl units={ units } value="5" /> );
const select = getSelect();
await user.selectOptions( select, [ 'vmax' ] );
const input = getInput();
await user.clear( input );
expect( select ).toHaveValue( 'vmax' );
} );
it( 'should run onBlur callback when the unit select is blurred', async () => {
const user = userEvent.setup();
const onUnitChangeSpy = jest.fn();
const onBlurSpy = jest.fn();
render(
<UnitControl
value="15px"
onUnitChange={ onUnitChangeSpy }
onBlur={ onBlurSpy }
/>
);
const select = getSelect();
await user.selectOptions( select, [ 'em' ] );
expect( onUnitChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onUnitChangeSpy ).toHaveBeenLastCalledWith(
'em',
expect.anything()
);
// Clicking document.body to trigger a blur event on the input.
await user.click( document.body );
expect( onBlurSpy ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'Unit Parser', () => {
it( 'should update unit after initial render and with new unit prop', async () => {
const { rerender } = render( <UnitControl value={ '10%' } /> );
const select = getSelect();
expect( select.value ).toBe( '%' );
rerender( <UnitControl value={ '20vh' } /> );
expect( select.value ).toBe( 'vh' );
} );
it( 'should fallback to default unit if parsed unit is invalid', () => {
render( <UnitControl value={ '10null' } /> );
expect( getSelect().value ).toBe( 'px' );
} );
it( 'should display valid CSS unit when not explicitly included in units list', () => {
render(
<UnitControl
value={ '10%' }
units={ [
{ value: 'px', label: 'px' },
{ value: 'em', label: 'em' },
] }
/>
);
const select = getSelect();
const options = getSelectOptions();
expect( select.value ).toBe( '%' );
expect( options.length ).toBe( 3 );
} );
} );
describe( 'Unit switching convenience', () => {
it.each( CSS_UNITS.map( ( { value } ) => value ) )(
'should move focus from the input to the unit select when typing the first character of %p',
async ( testUnit ) => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
const onUnitChangeSpy = jest.fn();
render(
<UnitControl
value={ '10%' }
onChange={ onChangeSpy }
onUnitChange={ onUnitChangeSpy }
/>
);
const input = getInput();
await user.clear( input );
await user.type( input, `55${ testUnit }` );
expect( getSelect() ).toHaveFocus();
// The unit character was not entered in the input.
expect( input ).toHaveValue( 55 );
// The correct expected behavior would be for onChangeSpy to be
// called 4 times, and for the last value it was called with to be
// `55${testUnit}`, but the test environment doesn't seem to change
// values on `select` elements when using the keyboard.
expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
expect( onChangeSpy ).toHaveBeenLastCalledWith(
'55%',
expect.anything()
);
}
);
} );
} );

View File

@@ -0,0 +1,288 @@
/**
* Internal dependencies
*/
import {
filterUnitsWithSettings,
useCustomUnits,
getValidParsedQuantityAndUnit,
getUnitsWithCurrentUnit,
parseQuantityAndUnitFromRawValue,
} from '../utils';
import type { WPUnitControlUnit } from '../types';
describe( 'UnitControl utils', () => {
describe( 'useCustomUnits', () => {
it( 'should return filtered css units', () => {
const cssUnits = [
{ value: 'px', label: 'pixel' },
{ value: '%', label: 'percent' },
];
const units = useCustomUnits( {
availableUnits: [ 'em', 'px' ],
units: cssUnits,
} );
expect( units ).toEqual( [ { value: 'px', label: 'pixel' } ] );
} );
it( 'should add default values to available units', () => {
const cssUnits = [
{ value: 'px', label: 'pixel' },
{ value: '%', label: 'percent' },
];
const units = useCustomUnits( {
availableUnits: [ '%', 'px' ],
defaultValues: { '%': 10, px: 10 },
units: cssUnits,
} );
expect( units ).toEqual( [
{ value: 'px', label: 'pixel', default: 10 },
{ value: '%', label: 'percent', default: 10 },
] );
} );
it( 'should add default values to available units even if the default values are strings', () => {
// Although the public APIs of the component expect a `number` as the type of the
// default values, it's still good to test for strings (as it can happen in un-typed
// environments)
const cssUnits = [
{ value: 'px', label: 'pixel' },
{ value: '%', label: 'percent' },
];
const units = useCustomUnits( {
availableUnits: [ '%', 'px' ],
defaultValues: {
// @ts-expect-error (passing a string instead of a number is the point of the test)
'%': '14',
// @ts-expect-error (passing a string instead of a number is the point of the test)
px: 'not a valid numeric quantity',
},
units: cssUnits,
} );
expect( units ).toEqual( [
{ value: 'px', label: 'pixel', default: undefined },
{ value: '%', label: 'percent', default: 14 },
] );
} );
it( 'should return an empty array where availableUnits match no preferred css units', () => {
const cssUnits = [
{ value: 'em', label: 'em' },
{ value: 'vh', label: 'vh' },
];
const units = useCustomUnits( {
availableUnits: [ '%', 'px' ],
defaultValues: { '%': 10, px: 10 },
units: cssUnits,
} );
expect( units ).toHaveLength( 0 );
} );
} );
describe( 'filterUnitsWithSettings', () => {
it( 'should return filtered units array', () => {
const preferredUnits = [ '%', 'px' ];
const availableUnits = [
{ value: 'px', label: 'pixel' },
{ value: 'em', label: 'em' },
];
expect(
filterUnitsWithSettings( preferredUnits, availableUnits )
).toEqual( [ { value: 'px', label: 'pixel' } ] );
} );
it( 'should return empty array where preferred units match no available css unit', () => {
const preferredUnits = [ '%', 'px' ];
const availableUnits = [ { value: 'em', label: 'em' } ];
expect(
filterUnitsWithSettings( preferredUnits, availableUnits )
).toEqual( [] );
} );
// Although the component's APIs and types don't allow for `false` as a value
// unit lists, it's good to keep this test around for backwards compat.
it( 'should return empty array where available units is set to false', () => {
const preferredUnits = [ '%', 'px' ];
const availableUnits = false;
expect(
// @ts-expect-error (passing `false` instead of a valid array of units is the point of the test)
filterUnitsWithSettings( preferredUnits, availableUnits )
).toEqual( [] );
} );
it( 'should return empty array where available units is set to an empty array', () => {
const preferredUnits = [ '%', 'px' ];
const availableUnits: WPUnitControlUnit[] = [];
expect(
filterUnitsWithSettings( preferredUnits, availableUnits )
).toEqual( [] );
} );
} );
describe( 'getValidParsedQuantityAndUnit', () => {
it( 'should parse valid number and unit', () => {
const nextValue = '42px';
expect( getValidParsedQuantityAndUnit( nextValue ) ).toEqual( [
42,
'px',
] );
} );
it( 'should return next value only where no known unit parsed', () => {
const nextValue = '365zz';
expect( getValidParsedQuantityAndUnit( nextValue ) ).toEqual( [
365,
undefined,
] );
} );
it( 'should return fallback value', () => {
const nextValue = 'thirteen';
const preferredUnits = [ { value: 'em', label: 'em' } ];
const fallbackValue = 13;
expect(
getValidParsedQuantityAndUnit(
nextValue,
preferredUnits,
fallbackValue
)
).toEqual( [ 13, 'em' ] );
} );
it( 'should return fallback unit', () => {
const nextValue = '911';
const fallbackUnit = '%';
expect(
getValidParsedQuantityAndUnit(
nextValue,
undefined,
undefined,
fallbackUnit
)
).toEqual( [ 911, '%' ] );
} );
it( 'should return first unit in preferred units collection as second fallback unit', () => {
const nextValue = 101;
const preferredUnits = [ { value: 'px', label: 'pixel' } ];
expect(
getValidParsedQuantityAndUnit( nextValue, preferredUnits )
).toEqual( [ 101, 'px' ] );
} );
} );
describe( 'getUnitsWithCurrentUnit', () => {
const limitedUnits = [
{
value: 'px',
label: 'px',
},
{
value: 'em',
label: 'em',
},
];
it( 'should return units list with valid current unit prepended', () => {
const result = getUnitsWithCurrentUnit(
'20%',
undefined,
limitedUnits
);
expect( result ).toHaveLength( 3 );
const currentUnit = result.shift();
expect( currentUnit?.value ).toBe( '%' );
expect( currentUnit?.label ).toBe( '%' );
expect( result ).toEqual( limitedUnits );
} );
it( 'should return units list with valid current unit prepended using legacy values', () => {
const result = getUnitsWithCurrentUnit( 20, '%', limitedUnits );
expect( result ).toHaveLength( 3 );
const currentUnit = result.shift();
expect( currentUnit?.value ).toBe( '%' );
expect( currentUnit?.label ).toBe( '%' );
expect( result ).toEqual( limitedUnits );
} );
it( 'should return units list without invalid current unit prepended', () => {
const result = getUnitsWithCurrentUnit(
'20null',
undefined,
limitedUnits
);
expect( result ).toHaveLength( 2 );
expect( result ).toEqual( limitedUnits );
} );
it( 'should return units list without an existing current unit prepended', () => {
const result = getUnitsWithCurrentUnit(
'20em',
undefined,
limitedUnits
);
expect( result ).toHaveLength( 2 );
expect( result ).toEqual( limitedUnits );
} );
} );
describe( 'parseQuantityAndUnitFromRawValue', () => {
const cases: [
number | string | undefined,
number | undefined,
string | undefined,
][] = [
// Test undefined.
[ undefined, undefined, undefined ],
// Test integers and non-integers.
[ 1, 1, undefined ],
[ 1.25, 1.25, undefined ],
[ '123', 123, undefined ],
[ '1.5', 1.5, undefined ],
[ '0.75', 0.75, undefined ],
// Valid simple CSS values.
[ '20px', 20, 'px' ],
[ '0.8em', 0.8, 'em' ],
[ '2rem', 2, 'rem' ],
[ '1.4vw', 1.4, 'vw' ],
[ '0.4vh', 0.4, 'vh' ],
[ '-5px', -5, 'px' ],
// Complex CSS values that shouldn't parse.
[ 'abs(-15px)', undefined, undefined ],
[ 'calc(10px + 1)', undefined, undefined ],
[ 'clamp(2.5rem, 4vw, 3rem)', undefined, undefined ],
[ 'max(4.5em, 3vh)', undefined, undefined ],
[ 'min(10px, 1rem)', undefined, undefined ],
[ 'minmax(30px, auto)', undefined, undefined ],
[ 'var(--wp--font-size)', undefined, undefined ],
];
test.each( cases )(
'given %p as argument, returns value = %p and unit = %p',
( rawValue, expectedQuantity, expectedUnit ) => {
const [ quantity, unit ] =
parseQuantityAndUnitFromRawValue( rawValue );
expect( quantity ).toBe( expectedQuantity );
expect( unit ).toBe( expectedUnit );
}
);
} );
} );

View File

@@ -0,0 +1,110 @@
/**
* External dependencies
*/
import type { FocusEventHandler } from 'react';
/**
* Internal dependencies
*/
import type {
InputChangeCallback,
InputControlProps,
} from '../input-control/types';
import type { NumberControlProps } from '../number-control/types';
export type SelectSize = 'default' | 'small';
export type WPUnitControlUnit = {
/**
* The value for the unit, used in a CSS value (e.g `px`).
*/
value: string;
/**
* The label used in a dropdown selector for the unit.
*/
label: string;
/**
* Default value (quantity) for the unit, used when switching units.
*/
default?: number;
/**
* An accessible label used by screen readers.
*/
a11yLabel?: string;
/**
* A step value used when incrementing/decrementing the value.
*/
step?: number;
};
export type UnitControlOnChangeCallback = InputChangeCallback< {
data?: WPUnitControlUnit;
} >;
export type UnitSelectControlProps = {
/**
* Whether the control can be focused via keyboard navigation.
*
* @default true
*/
isUnitSelectTabbable?: boolean;
/**
* A callback function invoked when the value is changed.
*/
onChange?: UnitControlOnChangeCallback;
/**
* The size of the unit select.
*/
size?: SelectSize;
/**
* Current unit.
*/
unit?: string;
/**
* Available units to select from.
*
* @default CSS_UNITS
*/
units?: WPUnitControlUnit[];
};
export type UnitControlProps = Pick< InputControlProps, 'size' > &
Omit< UnitSelectControlProps, 'size' | 'unit' > &
Omit< NumberControlProps, 'spinControls' | 'suffix' | 'type' > & {
/**
* If `true`, the unit `<select>` is hidden.
*
* @default false
*/
disableUnits?: boolean;
/**
* If `true`, and the selected unit provides a `default` value, this value is set
* when changing units.
*
* @default false
*/
isResetValueOnUnitChange?: boolean;
/**
* Callback when the `unit` changes.
*/
onUnitChange?: UnitControlOnChangeCallback;
/**
* Current unit. _Note: this prop is deprecated. Instead, provide a unit with a value through the `value` prop._
*
* @deprecated
*/
unit?: string;
/**
* Current value. If passed as a string, the current unit will be inferred from this value.
* For example, a `value` of "50%" will set the current unit to `%`.
*/
value?: string | number;
/**
* Callback when either the quantity or the unit inputs lose focus.
*/
onBlur?: FocusEventHandler< HTMLInputElement | HTMLSelectElement >;
/**
* Callback when either the quantity or the unit inputs gains focus.
*/
onFocus?: FocusEventHandler< HTMLInputElement | HTMLSelectElement >;
};

View File

@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type { ChangeEvent, ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import type { WordPressComponentProps } from '../context';
import { UnitSelect, UnitLabel } from './styles/unit-control-styles';
import { CSS_UNITS, hasUnits } from './utils';
import type { UnitSelectControlProps } from './types';
function UnitSelectControl(
{
className,
isUnitSelectTabbable: isTabbable = true,
onChange,
size = 'default',
unit = 'px',
units = CSS_UNITS,
...props
}: WordPressComponentProps< UnitSelectControlProps, 'select', false >,
ref: ForwardedRef< any >
) {
if ( ! hasUnits( units ) || units?.length === 1 ) {
return (
<UnitLabel
className="components-unit-control__unit-label"
selectSize={ size }
>
{ unit }
</UnitLabel>
);
}
const handleOnChange = ( event: ChangeEvent< HTMLSelectElement > ) => {
const { value: unitValue } = event.target;
const data = units.find( ( option ) => option.value === unitValue );
onChange?.( unitValue, { event, data } );
};
const classes = classnames( 'components-unit-control__select', className );
return (
<UnitSelect
ref={ ref }
className={ classes }
onChange={ handleOnChange }
selectSize={ size }
tabIndex={ isTabbable ? undefined : -1 }
value={ unit }
{ ...props }
>
{ units.map( ( option ) => (
<option value={ option.value } key={ option.value }>
{ option.label }
</option>
) ) }
</UnitSelect>
);
}
export default forwardRef( UnitSelectControl );

View File

@@ -0,0 +1,482 @@
/**
* WordPress dependencies
*/
import { __, _x } from '@wordpress/i18n';
import { Platform } from '@wordpress/element';
/**
* Internal dependencies
*/
import type { WPUnitControlUnit } from './types';
const isWeb = Platform.OS === 'web';
const allUnits: Record< string, WPUnitControlUnit > = {
px: {
value: 'px',
label: isWeb ? 'px' : __( 'Pixels (px)' ),
a11yLabel: __( 'Pixels (px)' ),
step: 1,
},
'%': {
value: '%',
label: isWeb ? '%' : __( 'Percentage (%)' ),
a11yLabel: __( 'Percent (%)' ),
step: 0.1,
},
em: {
value: 'em',
label: isWeb ? 'em' : __( 'Relative to parent font size (em)' ),
a11yLabel: _x( 'ems', 'Relative to parent font size (em)' ),
step: 0.01,
},
rem: {
value: 'rem',
label: isWeb ? 'rem' : __( 'Relative to root font size (rem)' ),
a11yLabel: _x( 'rems', 'Relative to root font size (rem)' ),
step: 0.01,
},
vw: {
value: 'vw',
label: isWeb ? 'vw' : __( 'Viewport width (vw)' ),
a11yLabel: __( 'Viewport width (vw)' ),
step: 0.1,
},
vh: {
value: 'vh',
label: isWeb ? 'vh' : __( 'Viewport height (vh)' ),
a11yLabel: __( 'Viewport height (vh)' ),
step: 0.1,
},
vmin: {
value: 'vmin',
label: isWeb ? 'vmin' : __( 'Viewport smallest dimension (vmin)' ),
a11yLabel: __( 'Viewport smallest dimension (vmin)' ),
step: 0.1,
},
vmax: {
value: 'vmax',
label: isWeb ? 'vmax' : __( 'Viewport largest dimension (vmax)' ),
a11yLabel: __( 'Viewport largest dimension (vmax)' ),
step: 0.1,
},
ch: {
value: 'ch',
label: isWeb ? 'ch' : __( 'Width of the zero (0) character (ch)' ),
a11yLabel: __( 'Width of the zero (0) character (ch)' ),
step: 0.01,
},
ex: {
value: 'ex',
label: isWeb ? 'ex' : __( 'x-height of the font (ex)' ),
a11yLabel: __( 'x-height of the font (ex)' ),
step: 0.01,
},
cm: {
value: 'cm',
label: isWeb ? 'cm' : __( 'Centimeters (cm)' ),
a11yLabel: __( 'Centimeters (cm)' ),
step: 0.001,
},
mm: {
value: 'mm',
label: isWeb ? 'mm' : __( 'Millimeters (mm)' ),
a11yLabel: __( 'Millimeters (mm)' ),
step: 0.1,
},
in: {
value: 'in',
label: isWeb ? 'in' : __( 'Inches (in)' ),
a11yLabel: __( 'Inches (in)' ),
step: 0.001,
},
pc: {
value: 'pc',
label: isWeb ? 'pc' : __( 'Picas (pc)' ),
a11yLabel: __( 'Picas (pc)' ),
step: 1,
},
pt: {
value: 'pt',
label: isWeb ? 'pt' : __( 'Points (pt)' ),
a11yLabel: __( 'Points (pt)' ),
step: 1,
},
svw: {
value: 'svw',
label: isWeb ? 'svw' : __( 'Small viewport width (svw)' ),
a11yLabel: __( 'Small viewport width (svw)' ),
step: 0.1,
},
svh: {
value: 'svh',
label: isWeb ? 'svh' : __( 'Small viewport height (svh)' ),
a11yLabel: __( 'Small viewport height (svh)' ),
step: 0.1,
},
svi: {
value: 'svi',
label: isWeb
? 'svi'
: __( 'Viewport smallest size in the inline direction (svi)' ),
a11yLabel: __( 'Small viewport width or height (svi)' ),
step: 0.1,
},
svb: {
value: 'svb',
label: isWeb
? 'svb'
: __( 'Viewport smallest size in the block direction (svb)' ),
a11yLabel: __( 'Small viewport width or height (svb)' ),
step: 0.1,
},
svmin: {
value: 'svmin',
label: isWeb
? 'svmin'
: __( 'Small viewport smallest dimension (svmin)' ),
a11yLabel: __( 'Small viewport smallest dimension (svmin)' ),
step: 0.1,
},
lvw: {
value: 'lvw',
label: isWeb ? 'lvw' : __( 'Large viewport width (lvw)' ),
a11yLabel: __( 'Large viewport width (lvw)' ),
step: 0.1,
},
lvh: {
value: 'lvh',
label: isWeb ? 'lvh' : __( 'Large viewport height (lvh)' ),
a11yLabel: __( 'Large viewport height (lvh)' ),
step: 0.1,
},
lvi: {
value: 'lvi',
label: isWeb ? 'lvi' : __( 'Large viewport width or height (lvi)' ),
a11yLabel: __( 'Large viewport width or height (lvi)' ),
step: 0.1,
},
lvb: {
value: 'lvb',
label: isWeb ? 'lvb' : __( 'Large viewport width or height (lvb)' ),
a11yLabel: __( 'Large viewport width or height (lvb)' ),
step: 0.1,
},
lvmin: {
value: 'lvmin',
label: isWeb
? 'lvmin'
: __( 'Large viewport smallest dimension (lvmin)' ),
a11yLabel: __( 'Large viewport smallest dimension (lvmin)' ),
step: 0.1,
},
dvw: {
value: 'dvw',
label: isWeb ? 'dvw' : __( 'Dynamic viewport width (dvw)' ),
a11yLabel: __( 'Dynamic viewport width (dvw)' ),
step: 0.1,
},
dvh: {
value: 'dvh',
label: isWeb ? 'dvh' : __( 'Dynamic viewport height (dvh)' ),
a11yLabel: __( 'Dynamic viewport height (dvh)' ),
step: 0.1,
},
dvi: {
value: 'dvi',
label: isWeb ? 'dvi' : __( 'Dynamic viewport width or height (dvi)' ),
a11yLabel: __( 'Dynamic viewport width or height (dvi)' ),
step: 0.1,
},
dvb: {
value: 'dvb',
label: isWeb ? 'dvb' : __( 'Dynamic viewport width or height (dvb)' ),
a11yLabel: __( 'Dynamic viewport width or height (dvb)' ),
step: 0.1,
},
dvmin: {
value: 'dvmin',
label: isWeb
? 'dvmin'
: __( 'Dynamic viewport smallest dimension (dvmin)' ),
a11yLabel: __( 'Dynamic viewport smallest dimension (dvmin)' ),
step: 0.1,
},
dvmax: {
value: 'dvmax',
label: isWeb
? 'dvmax'
: __( 'Dynamic viewport largest dimension (dvmax)' ),
a11yLabel: __( 'Dynamic viewport largest dimension (dvmax)' ),
step: 0.1,
},
svmax: {
value: 'svmax',
label: isWeb
? 'svmax'
: __( 'Small viewport largest dimension (svmax)' ),
a11yLabel: __( 'Small viewport largest dimension (svmax)' ),
step: 0.1,
},
lvmax: {
value: 'lvmax',
label: isWeb
? 'lvmax'
: __( 'Large viewport largest dimension (lvmax)' ),
a11yLabel: __( 'Large viewport largest dimension (lvmax)' ),
step: 0.1,
},
};
/**
* An array of all available CSS length units.
*/
export const ALL_CSS_UNITS = Object.values( allUnits );
/**
* Units of measurements. `a11yLabel` is used by screenreaders.
*/
export const CSS_UNITS = [
allUnits.px,
allUnits[ '%' ],
allUnits.em,
allUnits.rem,
allUnits.vw,
allUnits.vh,
];
export const DEFAULT_UNIT = allUnits.px;
/**
* Handles legacy value + unit handling.
* This component use to manage both incoming value and units separately.
*
* Moving forward, ideally the value should be a string that contains both
* the value and unit, example: '10px'
*
* @param rawValue The raw value as a string (may or may not contain the unit)
* @param fallbackUnit The unit used as a fallback, if not unit is detected in the `value`
* @param allowedUnits Units to derive from.
* @return The extracted quantity and unit. The quantity can be `undefined` in case the raw value
* could not be parsed to a number correctly. The unit can be `undefined` in case the unit parse
* from the raw value could not be matched against the list of allowed units.
*/
export function getParsedQuantityAndUnit(
rawValue?: string | number,
fallbackUnit?: string,
allowedUnits?: WPUnitControlUnit[]
): [ number | undefined, string | undefined ] {
const initialValue = fallbackUnit
? `${ rawValue ?? '' }${ fallbackUnit }`
: rawValue;
return parseQuantityAndUnitFromRawValue( initialValue, allowedUnits );
}
/**
* Checks if units are defined.
*
* @param units List of units.
* @return Whether the list actually contains any units.
*/
export function hasUnits(
units?: WPUnitControlUnit[]
): units is WPUnitControlUnit[] {
// Although the `isArray` check shouldn't be necessary (given the signature of
// this typed function), it's better to stay on the side of caution, since
// this function may be called from un-typed environments.
return Array.isArray( units ) && !! units.length;
}
/**
* Parses a quantity and unit from a raw string value, given a list of allowed
* units and otherwise falling back to the default unit.
*
* @param rawValue The raw value as a string (may or may not contain the unit)
* @param allowedUnits Units to derive from.
* @return The extracted quantity and unit. The quantity can be `undefined` in case the raw value
* could not be parsed to a number correctly. The unit can be `undefined` in case the unit parsed
* from the raw value could not be matched against the list of allowed units.
*/
export function parseQuantityAndUnitFromRawValue(
rawValue?: string | number,
allowedUnits: WPUnitControlUnit[] = ALL_CSS_UNITS
): [ number | undefined, string | undefined ] {
let trimmedValue;
let quantityToReturn;
if ( typeof rawValue !== 'undefined' || rawValue === null ) {
trimmedValue = `${ rawValue }`.trim();
const parsedQuantity = parseFloat( trimmedValue );
quantityToReturn = ! isFinite( parsedQuantity )
? undefined
: parsedQuantity;
}
const unitMatch = trimmedValue?.match( /[\d.\-\+]*\s*(.*)/ );
const matchedUnit = unitMatch?.[ 1 ]?.toLowerCase();
let unitToReturn: string | undefined;
if ( hasUnits( allowedUnits ) ) {
const match = allowedUnits.find(
( item ) => item.value === matchedUnit
);
unitToReturn = match?.value;
} else {
unitToReturn = DEFAULT_UNIT.value;
}
return [ quantityToReturn, unitToReturn ];
}
/**
* Parses quantity and unit from a raw value. Validates parsed value, using fallback
* value if invalid.
*
* @param rawValue The next value.
* @param allowedUnits Units to derive from.
* @param fallbackQuantity The fallback quantity, used in case it's not possible to parse a valid quantity from the raw value.
* @param fallbackUnit The fallback unit, used in case it's not possible to parse a valid unit from the raw value.
* @return The extracted quantity and unit. The quantity can be `undefined` in case the raw value
* could not be parsed to a number correctly, and the `fallbackQuantity` was also `undefined`. The
* unit can be `undefined` only if the unit parsed from the raw value could not be matched against
* the list of allowed units, the `fallbackQuantity` is also `undefined` and the list of
* `allowedUnits` is passed empty.
*/
export function getValidParsedQuantityAndUnit(
rawValue: string | number,
allowedUnits?: WPUnitControlUnit[],
fallbackQuantity?: number,
fallbackUnit?: string
): [ number | undefined, string | undefined ] {
const [ parsedQuantity, parsedUnit ] = parseQuantityAndUnitFromRawValue(
rawValue,
allowedUnits
);
// The parsed value from `parseQuantityAndUnitFromRawValue` should now be
// either a real number or undefined. If undefined, use the fallback value.
const quantityToReturn = parsedQuantity ?? fallbackQuantity;
// If no unit is parsed from the raw value, or if the fallback unit is not
// defined, use the first value from the list of allowed units as fallback.
let unitToReturn = parsedUnit || fallbackUnit;
if ( ! unitToReturn && hasUnits( allowedUnits ) ) {
unitToReturn = allowedUnits[ 0 ].value;
}
return [ quantityToReturn, unitToReturn ];
}
/**
* Takes a unit value and finds the matching accessibility label for the
* unit abbreviation.
*
* @param unit Unit value (example: `px`)
* @return a11y label for the unit abbreviation
*/
export function getAccessibleLabelForUnit( unit: string ): string | undefined {
const match = ALL_CSS_UNITS.find( ( item ) => item.value === unit );
return match?.a11yLabel ? match?.a11yLabel : match?.value;
}
/**
* Filters available units based on values defined a list of allowed unit values.
*
* @param allowedUnitValues Collection of allowed unit value strings.
* @param availableUnits Collection of available unit objects.
* @return Filtered units.
*/
export function filterUnitsWithSettings(
allowedUnitValues: string[] = [],
availableUnits: WPUnitControlUnit[]
): WPUnitControlUnit[] {
// Although the `isArray` check shouldn't be necessary (given the signature of
// this typed function), it's better to stay on the side of caution, since
// this function may be called from un-typed environments.
return Array.isArray( availableUnits )
? availableUnits.filter( ( unit ) =>
allowedUnitValues.includes( unit.value )
)
: [];
}
/**
* Custom hook to retrieve and consolidate units setting from add_theme_support().
* TODO: ideally this hook shouldn't be needed
* https://github.com/WordPress/gutenberg/pull/31822#discussion_r633280823
*
* @param args An object containing units, settingPath & defaultUnits.
* @param args.units Collection of all potentially available units.
* @param args.availableUnits Collection of unit value strings for filtering available units.
* @param args.defaultValues Collection of default values for defined units. Example: `{ px: 350, em: 15 }`.
*
* @return Filtered list of units, with their default values updated following the `defaultValues`
* argument's property.
*/
export const useCustomUnits = ( {
units = ALL_CSS_UNITS,
availableUnits = [],
defaultValues,
}: {
units?: WPUnitControlUnit[];
availableUnits?: string[];
defaultValues?: Record< string, number >;
} ): WPUnitControlUnit[] => {
const customUnitsToReturn = filterUnitsWithSettings(
availableUnits,
units
);
if ( defaultValues ) {
customUnitsToReturn.forEach( ( unit, i ) => {
if ( defaultValues[ unit.value ] ) {
const [ parsedDefaultValue ] = parseQuantityAndUnitFromRawValue(
defaultValues[ unit.value ]
);
customUnitsToReturn[ i ].default = parsedDefaultValue;
}
} );
}
return customUnitsToReturn;
};
/**
* Get available units with the unit for the currently selected value
* prepended if it is not available in the list of units.
*
* This is useful to ensure that the current value's unit is always
* accurately displayed in the UI, even if the intention is to hide
* the availability of that unit.
*
* @param rawValue Selected value to parse.
* @param legacyUnit Legacy unit value, if rawValue needs it appended.
* @param units List of available units.
*
* @return A collection of units containing the unit for the current value.
*/
export function getUnitsWithCurrentUnit(
rawValue?: string | number,
legacyUnit?: string,
units: WPUnitControlUnit[] = ALL_CSS_UNITS
): WPUnitControlUnit[] {
const unitsToReturn = Array.isArray( units ) ? [ ...units ] : [];
const [ , currentUnit ] = getParsedQuantityAndUnit(
rawValue,
legacyUnit,
ALL_CSS_UNITS
);
if (
currentUnit &&
! unitsToReturn.some( ( unit ) => unit.value === currentUnit )
) {
if ( allUnits[ currentUnit ] ) {
unitsToReturn.unshift( allUnits[ currentUnit ] );
}
}
return unitsToReturn;
}