Skip to content

Commit 0e5adff

Browse files
ciampojostnes
authored andcommitted
NumberControl: commit (and constrain) value on blur event (WordPress#39186)
* `InputControl`: always commit value to internal state on `blur` * `NumberControl`: constain value also on the `UPDATE` action * CHANGELOG * Add unit test * Remove `UPDATE` action from `InputControl` s state reducer * Add unit test to check that `onChange` gets called when the value is clamped on `blur` * Fix input control blur logic, so that `onChange` is called after clamping the value * Comments * Use `event.target.validity.valid` in docs, storybook and unit tests * README * Add docs and update example about input validity * Update `onChange` event type * Override `event.target`, update types * Revert docs and Storybook changes about `target.visibility` potentially not being defined
1 parent a818f1c commit 0e5adff

File tree

10 files changed

+143
-35
lines changed

10 files changed

+143
-35
lines changed

packages/components/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
- Delete the `composeStateReducers` utility function ([#39262](https://github.com/WordPress/gutenberg/pull/39262)).
1717
- `BoxControl`: stop using `UnitControl`'s deprecated `unit` prop ([#39511](https://github.com/WordPress/gutenberg/pull/39511)).
1818

19+
### Bug Fix
20+
21+
- `NumberControl`: commit (and constrain) value on `blur` event ([#39186](https://github.com/WordPress/gutenberg/pull/39186)).
22+
1923
## 19.6.0 (2022-03-11)
2024

2125
### Enhancements

packages/components/src/input-control/input-field.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ function InputField(
6767
pressEnter,
6868
pressUp,
6969
reset,
70-
update,
7170
} = useInputControlStateReducer( stateReducer, {
7271
isDragEnabled,
7372
value: valueProp,
@@ -91,10 +90,12 @@ function InputField(
9190
return;
9291
}
9392
if ( ! isFocused && ! wasDirtyOnBlur.current ) {
94-
update( valueProp, _event as SyntheticEvent );
93+
commit( valueProp, _event as SyntheticEvent );
9594
} else if ( ! isDirty ) {
9695
onChange( value, {
97-
event: _event as ChangeEvent< HTMLInputElement >,
96+
event: _event as
97+
| ChangeEvent< HTMLInputElement >
98+
| PointerEvent< HTMLInputElement >,
9899
} );
99100
wasDirtyOnBlur.current = false;
100101
}
@@ -108,7 +109,7 @@ function InputField(
108109
* If isPressEnterToChange is set, this commits the value to
109110
* the onChange callback.
110111
*/
111-
if ( isPressEnterToChange && isDirty ) {
112+
if ( isDirty || ! event.target.validity.valid ) {
112113
wasDirtyOnBlur.current = true;
113114
handleOnCommit( event );
114115
}
@@ -168,7 +169,18 @@ function InputField(
168169

169170
const dragGestureProps = useDrag< PointerEvent< HTMLInputElement > >(
170171
( dragProps ) => {
171-
const { distance, dragging, event } = dragProps;
172+
const { distance, dragging, event, target } = dragProps;
173+
174+
// The `target` prop always references the `input` element while, by
175+
// default, the `dragProps.event.target` property would reference the real
176+
// event target (i.e. any DOM element that the pointer is hovering while
177+
// dragging). Ensuring that the `target` is always the `input` element
178+
// allows consumers of `InputControl` (or any higher-level control) to
179+
// check the input's validity by accessing `event.target.validity.valid`.
180+
dragProps.event = {
181+
...dragProps.event,
182+
target,
183+
};
172184

173185
if ( ! distance ) return;
174186
event.stopPropagation();

packages/components/src/input-control/reducer/actions.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export const PRESS_DOWN = 'PRESS_DOWN';
1818
export const PRESS_ENTER = 'PRESS_ENTER';
1919
export const PRESS_UP = 'PRESS_UP';
2020
export const RESET = 'RESET';
21-
export const UPDATE = 'UPDATE';
2221

2322
interface EventPayload {
2423
event?: SyntheticEvent;
@@ -42,14 +41,9 @@ export type DragStartAction = Action< typeof DRAG_START, DragProps >;
4241
export type DragEndAction = Action< typeof DRAG_END, DragProps >;
4342
export type DragAction = Action< typeof DRAG, DragProps >;
4443
export type ResetAction = Action< typeof RESET, Partial< ValuePayload > >;
45-
export type UpdateAction = Action< typeof UPDATE, ValuePayload >;
4644
export type InvalidateAction = Action< typeof INVALIDATE, { error: unknown } >;
4745

48-
export type ChangeEventAction =
49-
| ChangeAction
50-
| ResetAction
51-
| CommitAction
52-
| UpdateAction;
46+
export type ChangeEventAction = ChangeAction | ResetAction | CommitAction;
5347

5448
export type DragEventAction = DragStartAction | DragEndAction | DragAction;
5549

packages/components/src/input-control/reducer/reducer.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,6 @@ function inputControlStateReducer(
100100
nextState.value = action.payload.value || state.initialValue;
101101
break;
102102

103-
case actions.UPDATE:
104-
nextState.value = action.payload.value;
105-
nextState.isDirty = false;
106-
break;
107-
108103
/**
109104
* Validation
110105
*/
@@ -197,7 +192,6 @@ export function useInputControlStateReducer(
197192
dispatch( { type: actions.INVALIDATE, payload: { error, event } } );
198193
const reset = createChangeEvent( actions.RESET );
199194
const commit = createChangeEvent( actions.COMMIT );
200-
const update = createChangeEvent( actions.UPDATE );
201195

202196
const dragStart = createDragEvent( actions.DRAG_START );
203197
const drag = createDragEvent( actions.DRAG );
@@ -220,6 +214,5 @@ export function useInputControlStateReducer(
220214
pressUp,
221215
reset,
222216
state,
223-
update,
224217
} as const;
225218
}

packages/components/src/input-control/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
ReactNode,
77
ChangeEvent,
88
SyntheticEvent,
9+
PointerEvent,
910
} from 'react';
1011
import type { useDrag } from '@use-gesture/react';
1112

@@ -33,7 +34,7 @@ interface BaseProps {
3334
}
3435

3536
export type InputChangeCallback<
36-
E = ChangeEvent< HTMLInputElement >,
37+
E = ChangeEvent< HTMLInputElement > | PointerEvent< HTMLInputElement >,
3738
P = {}
3839
> = ( nextValue: string | undefined, extra: { event: E } & P ) => void;
3940

packages/components/src/number-control/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@ The minimum `value` allowed.
9797
- Required: No
9898
- Default: `-Infinity`
9999

100+
### onChange
101+
102+
Callback fired whenever the value of the input changes.
103+
104+
The callback receives two arguments:
105+
106+
1. `newValue`: the new value of the input
107+
2. `extra`: an object containing, under the `event` key, the original browser event.
108+
109+
Note that the value received as the first argument of the callback is _not_ guaranteed to be a valid value (e.g. it could be outside of the range defined by the [`min`, `max`] props, or it could not match the `step`). In order to check the value's validity, check the `event.target?.validity.valid` property from the callback's second argument.
110+
111+
- Type: `(newValue, extra) => void`
112+
- Required: No
113+
100114
### required
101115

102116
If `true` enforces a valid number within the control's min/max range. If `false` allows an empty string as a valid value.

packages/components/src/number-control/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export function NumberControl(
148148
}
149149

150150
/**
151-
* Handles commit (ENTER key press or on blur if isPressEnterToChange)
151+
* Handles commit (ENTER key press or blur)
152152
*/
153153
if (
154154
type === inputControlActionTypes.PRESS_ENTER ||

packages/components/src/number-control/stories/index.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default {
2323

2424
function Example() {
2525
const [ value, setValue ] = useState( '0' );
26+
const [ isValidValue, setIsValidValue ] = useState( true );
2627

2728
const props = {
2829
disabled: boolean( 'disabled', false ),
@@ -32,18 +33,24 @@ function Example() {
3233
label: text( 'label', 'Number' ),
3334
min: number( 'min', 0 ),
3435
max: number( 'max', 100 ),
35-
placeholder: text( 'placeholder', 0 ),
36+
placeholder: text( 'placeholder', '0' ),
3637
required: boolean( 'required', false ),
3738
shiftStep: number( 'shiftStep', 10 ),
38-
step: text( 'step', 1 ),
39+
step: text( 'step', '1' ),
3940
};
4041

4142
return (
42-
<NumberControl
43-
{ ...props }
44-
value={ value }
45-
onChange={ ( v ) => setValue( v ) }
46-
/>
43+
<>
44+
<NumberControl
45+
{ ...props }
46+
value={ value }
47+
onChange={ ( v, extra ) => {
48+
setValue( v );
49+
setIsValidValue( extra.event.target.validity.valid );
50+
} }
51+
/>
52+
<p>Is valid? { isValidValue ? 'Yes' : 'No' }</p>
53+
</>
4754
);
4855
}
4956

packages/components/src/number-control/test/index.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* External dependencies
33
*/
4-
import { render, screen, fireEvent } from '@testing-library/react';
4+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
55

66
/**
77
* WordPress dependencies
@@ -63,6 +63,68 @@ describe( 'NumberControl', () => {
6363

6464
expect( spy ).toHaveBeenCalledWith( '10' );
6565
} );
66+
67+
it( 'should call onChange callback when value is clamped on blur', async () => {
68+
const spy = jest.fn();
69+
render(
70+
<NumberControl
71+
value={ 5 }
72+
min={ 4 }
73+
max={ 10 }
74+
onChange={ ( v ) => spy( v ) }
75+
/>
76+
);
77+
78+
const input = getInput();
79+
input.focus();
80+
fireEvent.change( input, { target: { value: 1 } } );
81+
82+
// Before blurring, the value is still un-clamped
83+
expect( input.value ).toBe( '1' );
84+
85+
input.blur();
86+
87+
// After blur, value is clamped
88+
expect( input.value ).toBe( '4' );
89+
90+
// After the blur, the `onChange` callback fires asynchronously.
91+
await waitFor( () => {
92+
expect( spy ).toHaveBeenCalledTimes( 2 );
93+
expect( spy ).toHaveBeenNthCalledWith( 1, '1' );
94+
expect( spy ).toHaveBeenNthCalledWith( 2, 4 );
95+
} );
96+
} );
97+
98+
it( 'should call onChange callback when value is not valid', () => {
99+
const spy = jest.fn();
100+
render(
101+
<NumberControl
102+
value={ 5 }
103+
min={ 1 }
104+
max={ 10 }
105+
onChange={ ( v, extra ) =>
106+
spy( v, extra.event.target.validity.valid )
107+
}
108+
/>
109+
);
110+
111+
const input = getInput();
112+
input.focus();
113+
fireEvent.change( input, { target: { value: 14 } } );
114+
115+
expect( input.value ).toBe( '14' );
116+
117+
fireKeyDown( { keyCode: ENTER } );
118+
119+
expect( input.value ).toBe( '10' );
120+
121+
expect( spy ).toHaveBeenCalledTimes( 2 );
122+
123+
// First call: invalid, unclamped value
124+
expect( spy ).toHaveBeenNthCalledWith( 1, '14', false );
125+
// Second call: valid, clamped value
126+
expect( spy ).toHaveBeenNthCalledWith( 2, 10, true );
127+
} );
66128
} );
67129

68130
describe( 'Validation', () => {
@@ -82,6 +144,22 @@ describe( 'NumberControl', () => {
82144
expect( input.value ).toBe( '0' );
83145
} );
84146

147+
it( 'should clamp value within range on blur', () => {
148+
render( <NumberControl value={ 5 } min={ 0 } max={ 10 } /> );
149+
150+
const input = getInput();
151+
input.focus();
152+
fireEvent.change( input, { target: { value: 41 } } );
153+
154+
// Before blurring, the value is still un-clamped
155+
expect( input.value ).toBe( '41' );
156+
157+
input.blur();
158+
159+
// After blur, value is clamped
160+
expect( input.value ).toBe( '10' );
161+
} );
162+
85163
it( 'should parse to number value on ENTER keypress when required', () => {
86164
render( <NumberControl value={ 5 } required={ true } /> );
87165

packages/components/src/unit-control/index.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ForwardedRef,
88
SyntheticEvent,
99
ChangeEvent,
10+
PointerEvent,
1011
} from 'react';
1112
import { omit } from 'lodash';
1213
import classnames from 'classnames';
@@ -45,7 +46,7 @@ function UnforwardedUnitControl(
4546
isResetValueOnUnitChange = false,
4647
isUnitSelectTabbable = true,
4748
label,
48-
onChange,
49+
onChange: onChangeProp,
4950
onUnitChange,
5051
size = 'default',
5152
style,
@@ -89,14 +90,18 @@ function UnforwardedUnitControl(
8990

9091
const handleOnQuantityChange = (
9192
nextQuantityValue: number | string | undefined,
92-
changeProps: { event: ChangeEvent< HTMLInputElement > }
93+
changeProps: {
94+
event:
95+
| ChangeEvent< HTMLInputElement >
96+
| PointerEvent< HTMLInputElement >;
97+
}
9398
) => {
9499
if (
95100
nextQuantityValue === '' ||
96101
typeof nextQuantityValue === 'undefined' ||
97102
nextQuantityValue === null
98103
) {
99-
onChange?.( '', changeProps );
104+
onChangeProp?.( '', changeProps );
100105
return;
101106
}
102107

@@ -111,7 +116,7 @@ function UnforwardedUnitControl(
111116
unit
112117
).join( '' );
113118

114-
onChange?.( onChangeValue, changeProps );
119+
onChangeProp?.( onChangeValue, changeProps );
115120
};
116121

117122
const handleOnUnitChange: UnitControlOnChangeCallback = (
@@ -126,7 +131,7 @@ function UnforwardedUnitControl(
126131
nextValue = `${ data.default }${ nextUnitValue }`;
127132
}
128133

129-
onChange?.( nextValue, changeProps );
134+
onChangeProp?.( nextValue, changeProps );
130135
onUnitChange?.( nextUnitValue, changeProps );
131136

132137
setUnit( nextUnitValue );
@@ -155,7 +160,7 @@ function UnforwardedUnitControl(
155160
: undefined;
156161
const changeProps = { event, data };
157162

158-
onChange?.(
163+
onChangeProp?.(
159164
`${ validParsedQuantity ?? '' }${ validParsedUnit }`,
160165
changeProps
161166
);

0 commit comments

Comments
 (0)