-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
base: trunk
Are you sure you want to change the base?
Changes from all commits
6700c80
8f8c5ca
1a3cb9c
1282d27
cfe7f1d
5c1f979
6b427ed
ab442dd
5f4b612
f5a3978
f7822d2
5c45bff
a57be10
41c3345
1da7361
3ac25c9
bc58827
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 > | ||
|
@@ -60,40 +69,62 @@ function UnforwardedNumberControl( | |
spinControls = 'none'; | ||
} | ||
|
||
if ( typeof valueProp === 'number' ) { | ||
// TODO: deprecate `value` as a `number` | ||
} | ||
|
||
const valuePropAsString = | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Key change: from this line on, |
||
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 | ||
); | ||
}; | ||
|
||
/** | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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; | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was solved by wrapping the result of |
||
nextState.value = constrainValue( | ||
// @ts-expect-error TODO: Investigate if it's ok for currentValue to be undefined | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solved this by adding a pre-computation variable ( |
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was solved by wrapping the result of |
||
nextState.value = applyEmptyValue | ||
? currentValue | ||
: // @ts-expect-error TODO: Investigate if it's ok for currentValue to be undefined | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solved this TODO by adding the |
||
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 | ||
|
@@ -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; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 > = ( { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
</> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Changes in this file reflect the bug fix related to |
||
} ); | ||
|
||
it( 'should call onChange callback when value is not valid', async () => { | ||
|
@@ -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 ); | ||
} ); | ||
} ); | ||
|
||
|
There was a problem hiding this comment.
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 astring
for thevalue
propThere was a problem hiding this comment.
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.There was a problem hiding this comment.
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 thevalue
prop for a few reasons: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)''
to represent an empty value (whileundefined
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