Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NumberControl: tidy up and fix types around value prop #45982

Open
wants to merge 17 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- `Dropdown`: deprecate `position` prop, use `popoverProps` instead ([46865](https://github.com/WordPress/gutenberg/pull/46865)).
- `Button`: improve padding for buttons with icon and text. ([46764](https://github.com/WordPress/gutenberg/pull/46764)).
- `NumberControl`: tidy up internal types and remove `@ts-expect-error` comments ([#45982](https://github.com/WordPress/gutenberg/pull/45982)).

### Internal

Expand All @@ -21,6 +22,7 @@
- `Placeholder`: set fixed right margin for label's icon ([46918](https://github.com/WordPress/gutenberg/pull/46918)).
- `TreeGrid`: Fix right-arrow keyboard navigation when a row contains more than two focusable elements ([46998](https://github.com/WordPress/gutenberg/pull/46998)).
- `TabPanel`: Fix initial tab selection when the tab declaration is lazily added to the `tabs` array ([47100](https://github.com/WordPress/gutenberg/pull/47100)).
- `NumberControl`: make sure `onChange`'s argument is always a `string` ([#45982](https://github.com/WordPress/gutenberg/pull/45982)).

## 23.1.0 (2023-01-02)

Expand Down
204 changes: 141 additions & 63 deletions packages/components/src/number-control/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,34 +19,43 @@ import deprecated from '@wordpress/deprecated';
import { Input, SpinButton } from './styles/number-control-styles';
import * as inputControlActionTypes from '../input-control/reducer/actions';
import { add, subtract, roundClamp } from '../utils/math';
import { ensureNumber, isValueEmpty } from '../utils/values';
import {
ensureFiniteNumber,
ensureFiniteNumberAsString,
isValueEmpty,
} from '../utils/values';
import type { WordPressComponentProps } from '../ui/context/wordpress-component';
import type { NumberControlProps } from './types';
import { HStack } from '../h-stack';
import { Spacer } from '../spacer';

const noop = () => {};
const DEFAULT_STEP = 1;
const DEFAULT_SHIFT_STEP = 10;

function UnforwardedNumberControl(
{
__unstableStateReducer: stateReducerProp,
className,
dragDirection = 'n',
hideHTMLArrows = false,
spinControls = 'native',
isDragEnabled = true,
isShiftStepEnabled = true,
label,
max = Infinity,
min = -Infinity,
required = false,
shiftStep = 10,
step = 1,
shiftStep = DEFAULT_SHIFT_STEP,
step = DEFAULT_STEP,
type: typeProp = 'number',
value: valueProp,
size = 'default',
suffix,
onChange = noop,
onChange,

// Deprecated
hideHTMLArrows = false,

// Rest
...props
}: WordPressComponentProps< NumberControlProps, 'input', false >,
forwardedRef: ForwardedRef< any >
Expand All @@ -60,40 +69,62 @@ function UnforwardedNumberControl(
spinControls = 'none';
}

if ( typeof valueProp === 'number' ) {
// TODO: deprecate `value` as a `number`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be implemented in a later PR, once all usages of NumberControl are migrated to using a string for the value prop

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd prefer that number always be accepted. From a practical standpoint its convenient and logical (i.e. numbers are more fit than strings since strings may not be numeric). I'd be happy to learn about anything I'm overlooking though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided to use string as the type for the value prop for a few reasons:

  • we wanted to have one recommended way to pass value, since the ambiguity of this type is creeping across many higher level components (we're noticing this especially as we try to make progress with the TypeScript migration)
  • this type aligns closer with native HTML input elements
  • using string would also align with the convention according to which controlled components pass '' to represent an empty value (while undefined denotes the uncontrolled version of the component).

We won't drop support for number in order not to introduce a breaking change, but the plan is to discourage it by marking it as a deprecated type for the prop

}

const valuePropAsString =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key change: from this line on, NumberControl treats the incoming value as a string

valueProp !== undefined
? ensureFiniteNumberAsString( valueProp ) ?? undefined
: undefined;
const shiftStepAsNumber =
ensureFiniteNumber( shiftStep ) ?? DEFAULT_SHIFT_STEP;

const inputRef = useRef< HTMLInputElement >();
const mergedRef = useMergeRefs( [ inputRef, forwardedRef ] );

const isStepAny = step === 'any';
const baseStep = isStepAny ? 1 : ensureNumber( step );
// Base step is `1` when `step="any". Use `1` as a fallback in case
// `step` prop couldn't be parsed to a finite number.
const baseStep = isStepAny
? DEFAULT_STEP
: ensureFiniteNumber( step ) ?? DEFAULT_STEP;
const baseValue = roundClamp( 0, min, max, baseStep );
const constrainValue = (
value: number | string,
stepOverride?: number
) => {
const constrainValue = ( value: number, stepOverride?: number ) => {
// When step is "any" clamp the value, otherwise round and clamp it.
return isStepAny
? Math.min( max, Math.max( min, ensureNumber( value ) ) )
? Math.min( max, Math.max( min, value ) )
: roundClamp( value, min, max, stepOverride ?? baseStep );
};

const autoComplete = typeProp === 'number' ? 'off' : undefined;
const classes = classNames( 'components-number-control', className );

/**
* Computes the new value when the current value needs to change following a
* "spin" event (i.e. up or down arrow, up or down spin buttons)
*/
const spinValue = (
value: string | number | undefined,
value: number,
direction: 'up' | 'down',
event: KeyboardEvent | MouseEvent | undefined
) => {
event?.preventDefault();
const shift = event?.shiftKey && isShiftStepEnabled;
const delta = shift ? ensureNumber( shiftStep ) * baseStep : baseStep;
let nextValue = isValueEmpty( value ) ? baseValue : value;
if ( direction === 'up' ) {
nextValue = add( nextValue, delta );
} else if ( direction === 'down' ) {
nextValue = subtract( nextValue, delta );
}
return constrainValue( nextValue, shift ? delta : undefined );
const enableShift = event?.shiftKey && isShiftStepEnabled;

const computedStep = enableShift
? shiftStepAsNumber * baseStep
: baseStep;

const nextValue =
direction === 'up'
? add( value, computedStep )
: subtract( value, computedStep );

return constrainValue(
nextValue,
enableShift ? computedStep : undefined
);
};

/**
Expand All @@ -107,35 +138,53 @@ function UnforwardedNumberControl(
( state, action ) => {
const nextState = { ...state };

const { type, payload } = action;
const event = payload.event;
const currentValue = nextState.value;

/**
* Handles custom UP and DOWN Keyboard events
*/
if (
type === inputControlActionTypes.PRESS_UP ||
type === inputControlActionTypes.PRESS_DOWN
action.type === inputControlActionTypes.PRESS_UP ||
action.type === inputControlActionTypes.PRESS_DOWN
) {
// @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components
ciampo marked this conversation as resolved.
Show resolved Hide resolved
nextState.value = spinValue(
currentValue,
type === inputControlActionTypes.PRESS_UP ? 'up' : 'down',
event as KeyboardEvent | undefined
const actionEvent = (
action as inputControlActionTypes.KeyEventAction
).payload.event;
const valueToSpin = isValueEmpty( currentValue )
? baseValue
: ensureFiniteNumber( currentValue ) ?? baseValue;

const nextValue = ensureFiniteNumberAsString(
spinValue(
valueToSpin,
action.type === inputControlActionTypes.PRESS_UP
? 'up'
: 'down',
actionEvent as KeyboardEvent
)
);

if ( nextValue !== null ) {
nextState.value = nextValue;
}
}

/**
* Handles drag to update events
*/
if ( type === inputControlActionTypes.DRAG && isDragEnabled ) {
// @ts-expect-error TODO: See if reducer actions can be typed better
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to allow better action typing, I removed the destructuring of the action argument from the top of the function. This allows the code to typecast action to the specific action inside each if clause, which makes TypeScript happy!

const [ x, y ] = payload.delta;
// @ts-expect-error TODO: See if reducer actions can be typed better
const enableShift = payload.shiftKey && isShiftStepEnabled;
const modifier = enableShift
? ensureNumber( shiftStep ) * baseStep
if (
action.type === inputControlActionTypes.DRAG &&
isDragEnabled
) {
const dragPayload = (
action as inputControlActionTypes.DragAction
).payload;
const [ x, y ] = dragPayload.delta;

// `shiftKey` comes via the `useDrag` hook
const enableShift = dragPayload.shiftKey && isShiftStepEnabled;
const computedStep = enableShift
? shiftStepAsNumber * baseStep
: baseStep;

let directionModifier;
Expand Down Expand Up @@ -165,48 +214,78 @@ function UnforwardedNumberControl(

if ( delta !== 0 ) {
delta = Math.ceil( Math.abs( delta ) ) * Math.sign( delta );
const distance = delta * modifier * directionModifier;
const distance = delta * computedStep * directionModifier;

// @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was solved by wrapping the result of constrainValue in ensureString

nextState.value = constrainValue(
// @ts-expect-error TODO: Investigate if it's ok for currentValue to be undefined
Copy link
Contributor Author

@ciampo ciampo Dec 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solved this by adding a pre-computation variable (valueToConstrain), which should not introduce new behavior to the component

add( currentValue, distance ),
enableShift ? modifier : undefined
const valueToConstrain = isValueEmpty( currentValue )
? baseValue
: ensureFiniteNumber( currentValue ) ?? baseValue;

const nextValue = ensureFiniteNumberAsString(
constrainValue(
add( valueToConstrain, distance ),
enableShift ? computedStep : undefined
)
);

if ( nextValue !== null ) {
nextState.value = nextValue;
}
}
}

/**
* Handles commit (ENTER key press or blur)
*/
if (
type === inputControlActionTypes.PRESS_ENTER ||
type === inputControlActionTypes.COMMIT
action.type === inputControlActionTypes.PRESS_ENTER ||
action.type === inputControlActionTypes.COMMIT
) {
const applyEmptyValue =
required === false && currentValue === '';
currentValue === undefined ||
( required === false && currentValue === '' );

const nextValue = applyEmptyValue
? ''
: ensureFiniteNumberAsString(
constrainValue(
ensureFiniteNumber( currentValue ) ?? baseValue
)
);

// @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was solved by wrapping the result of constrainValue in ensureString

nextState.value = applyEmptyValue
? currentValue
: // @ts-expect-error TODO: Investigate if it's ok for currentValue to be undefined
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solved this TODO by adding the currentValue === undefined check when computing the applyEmptyValue variable a few lines above

constrainValue( currentValue );
if ( nextValue !== null ) {
nextState.value = nextValue;
}
}

return nextState;
};

const buildSpinButtonClickHandler =
( direction: 'up' | 'down' ) =>
( event: MouseEvent< HTMLButtonElement > ) =>
onChange( String( spinValue( valueProp, direction, event ) ), {
// Set event.target to the <input> so that consumers can use
// e.g. event.target.validity.
event: {
...event,
target: inputRef.current!,
},
} );
( event: MouseEvent< HTMLButtonElement > ) => {
if ( onChange === undefined ) {
return;
}

const valueToSpin = isValueEmpty( valuePropAsString )
? baseValue
: ensureFiniteNumber( valuePropAsString ) ?? baseValue;

const onChangeValue = ensureFiniteNumberAsString(
spinValue( valueToSpin, direction, event )
);

if ( onChangeValue !== null ) {
return onChange( onChangeValue, {
// Set event.target to the <input> so that consumers can use
// e.g. event.target.validity.
event: {
...event,
target: inputRef.current!,
},
} );
}
};

return (
<Input
Expand All @@ -224,8 +303,7 @@ function UnforwardedNumberControl(
required={ required }
step={ step }
type={ typeProp }
// @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components
value={ valueProp }
value={ valuePropAsString }
__unstableStateReducer={ ( state, action ) => {
const baseState = numberControlStateReducer( state, action );
return stateReducerProp?.( baseState, action ) ?? baseState;
Expand Down
25 changes: 25 additions & 0 deletions packages/components/src/number-control/stories/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,28 @@ export const Default = Template.bind( {} );
Default.args = {
label: 'Value',
};

// Check if this was broken and at what point
// in particular on commit actions, when `min` is not `undefined`
export const Uncontrolled: ComponentStory< typeof NumberControl > = ( {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Storybook example can be removed before merging — it's mostly here to help manual testing with the uncontrolled version of the component

onChange,
...props
} ) => {
const [ isValidValue, setIsValidValue ] = useState( true );

return (
<>
<NumberControl
{ ...props }
onChange={ ( v, extra ) => {
setIsValidValue(
( extra.event.target as HTMLInputElement ).validity
.valid
);
onChange?.( v, extra );
} }
/>
<p>Is valid? { isValidValue ? 'Yes' : 'No' }</p>
</>
);
};
4 changes: 2 additions & 2 deletions packages/components/src/number-control/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe( 'NumberControl', () => {
// Second call: type '1'
expect( onChangeSpy ).toHaveBeenNthCalledWith( 2, '1', false );
// Third call: clamp value
expect( onChangeSpy ).toHaveBeenNthCalledWith( 3, 4, true );
expect( onChangeSpy ).toHaveBeenNthCalledWith( 3, '4', true );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this file reflect the bug fix related to onChange's signature — with the changes in this PR, its argument is always a string (in line with existing documentation)

} );

it( 'should call onChange callback when value is not valid', async () => {
Expand Down Expand Up @@ -139,7 +139,7 @@ describe( 'NumberControl', () => {
// Third call: invalid, unclamped value
expect( onChangeSpy ).toHaveBeenNthCalledWith( 3, '14', false );
// Fourth call: valid, clamped value
expect( onChangeSpy ).toHaveBeenNthCalledWith( 4, 10, true );
expect( onChangeSpy ).toHaveBeenNthCalledWith( 4, '10', true );
} );
} );

Expand Down
Loading