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: commit (and constrain) value on blur event #39186

Merged
merged 14 commits into from
Mar 21, 2022
Merged
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
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
- Delete the `composeStateReducers` utility function ([#39262](https://github.com/WordPress/gutenberg/pull/39262)).
- `BoxControl`: stop using `UnitControl`'s deprecated `unit` prop ([#39511](https://github.com/WordPress/gutenberg/pull/39511)).

### Bug Fix

- `NumberControl`: commit (and constrain) value on `blur` event ([#39186](https://github.com/WordPress/gutenberg/pull/39186)).

## 19.6.0 (2022-03-11)

### Enhancements
Expand Down
22 changes: 17 additions & 5 deletions packages/components/src/input-control/input-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ function InputField(
pressEnter,
pressUp,
reset,
update,
} = useInputControlStateReducer( stateReducer, {
isDragEnabled,
value: valueProp,
Expand All @@ -91,10 +90,12 @@ function InputField(
return;
}
if ( ! isFocused && ! wasDirtyOnBlur.current ) {
update( valueProp, _event as SyntheticEvent );
commit( valueProp, _event as SyntheticEvent );
} else if ( ! isDirty ) {
onChange( value, {
event: _event as ChangeEvent< HTMLInputElement >,
event: _event as
| ChangeEvent< HTMLInputElement >
| PointerEvent< HTMLInputElement >,
} );
wasDirtyOnBlur.current = false;
}
Expand All @@ -108,7 +109,7 @@ function InputField(
* If isPressEnterToChange is set, this commits the value to
* the onChange callback.
*/
if ( isPressEnterToChange && isDirty ) {
if ( isDirty || ! event.target.validity.valid ) {
wasDirtyOnBlur.current = true;
handleOnCommit( event );
}
Expand Down Expand Up @@ -168,7 +169,18 @@ function InputField(

const dragGestureProps = useDrag< PointerEvent< HTMLInputElement > >(
( dragProps ) => {
const { distance, dragging, event } = dragProps;
const { distance, dragging, event, target } = dragProps;

// The `target` prop always references the `input` element while, by
// default, the `dragProps.event.target` property would reference the real
// event target (i.e. any DOM element that the pointer is hovering while
// dragging). Ensuring that the `target` is always the `input` element
// allows consumers of `InputControl` (or any higher-level control) to
// check the input's validity by accessing `event.target.validity.valid`.
dragProps.event = {
...dragProps.event,
target,
};

if ( ! distance ) return;
event.stopPropagation();
Expand Down
8 changes: 1 addition & 7 deletions packages/components/src/input-control/reducer/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const PRESS_DOWN = 'PRESS_DOWN';
export const PRESS_ENTER = 'PRESS_ENTER';
export const PRESS_UP = 'PRESS_UP';
export const RESET = 'RESET';
export const UPDATE = 'UPDATE';

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

export type ChangeEventAction =
| ChangeAction
| ResetAction
| CommitAction
| UpdateAction;
export type ChangeEventAction = ChangeAction | ResetAction | CommitAction;

export type DragEventAction = DragStartAction | DragEndAction | DragAction;

Expand Down
7 changes: 0 additions & 7 deletions packages/components/src/input-control/reducer/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,6 @@ function inputControlStateReducer(
nextState.value = action.payload.value || state.initialValue;
break;

case actions.UPDATE:
nextState.value = action.payload.value;
nextState.isDirty = false;
break;

/**
* Validation
*/
Expand Down Expand Up @@ -197,7 +192,6 @@ export function useInputControlStateReducer(
dispatch( { type: actions.INVALIDATE, payload: { error, event } } );
const reset = createChangeEvent( actions.RESET );
const commit = createChangeEvent( actions.COMMIT );
const update = createChangeEvent( actions.UPDATE );

const dragStart = createDragEvent( actions.DRAG_START );
const drag = createDragEvent( actions.DRAG );
Expand All @@ -220,6 +214,5 @@ export function useInputControlStateReducer(
pressUp,
reset,
state,
update,
} as const;
}
3 changes: 2 additions & 1 deletion packages/components/src/input-control/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ReactNode,
ChangeEvent,
SyntheticEvent,
PointerEvent,
} from 'react';
import type { useDrag } from '@use-gesture/react';

Expand Down Expand Up @@ -33,7 +34,7 @@ interface BaseProps {
}

export type InputChangeCallback<
E = ChangeEvent< HTMLInputElement >,
E = ChangeEvent< HTMLInputElement > | PointerEvent< HTMLInputElement >,
P = {}
> = ( nextValue: string | undefined, extra: { event: E } & P ) => void;

Expand Down
14 changes: 14 additions & 0 deletions packages/components/src/number-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ The minimum `value` allowed.
- Required: No
- Default: `-Infinity`

### onChange

Callback fired whenever the value of the input changes.

The callback receives two arguments:

1. `newValue`: the new value of the input
2. `extra`: an object containing, under the `event` key, the original browser event.
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 have a slight (though not at all strong) preference for having the second param be the plain event object rather than nesting it within an extra object. There's a familiarity with having the second param of callbacks be the event object itself, so it might make this component slightly easier for someone new to looking at it if we don't add in this abstraction.

But I'm aware you probably added it in mostly thinking of the isValid flag? I think given the documentation now covers event.target.validity.valid, we'd probably be fine not adding in any additional metadata.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Chiming in to say the shape of that goes back well before this PR. I've suggested the same #33696 (comment). My feeling is the components are experimental so we have license to make the change as long as we fix up any internal stuff that might break. Good for a separate PR 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.

As @stokesman mentioned, the extra object existed before this PR and is defined in InputControl.

For example, UnitControl extends that extra object by adding a custom data property too (which seems to include extra info about which unit is being selected).

Given the above, I'm not sure what would be the best way to simplify the signature of the onChange callback — but it's definitely something that can be done in a follow-up

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, excellent, thanks for linking the prior discussion, glad I'm not alone there! 😄

Yes, I think that's perfectly fine for a follow-up, not a blocker for me!


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.

- Type: `(newValue, extra) => void`
- Required: No

### required

If `true` enforces a valid number within the control's min/max range. If `false` allows an empty string as a valid value.
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/number-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function NumberControl(
}

/**
* Handles commit (ENTER key press or on blur if isPressEnterToChange)
* Handles commit (ENTER key press or blur)
*/
if (
type === inputControlActionTypes.PRESS_ENTER ||
Expand Down
21 changes: 14 additions & 7 deletions packages/components/src/number-control/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default {

function Example() {
const [ value, setValue ] = useState( '0' );
const [ isValidValue, setIsValidValue ] = useState( true );

const props = {
disabled: boolean( 'disabled', false ),
Expand All @@ -32,18 +33,24 @@ function Example() {
label: text( 'label', 'Number' ),
min: number( 'min', 0 ),
max: number( 'max', 100 ),
placeholder: text( 'placeholder', 0 ),
placeholder: text( 'placeholder', '0' ),
required: boolean( 'required', false ),
shiftStep: number( 'shiftStep', 10 ),
step: text( 'step', 1 ),
step: text( 'step', '1' ),
};

return (
<NumberControl
{ ...props }
value={ value }
onChange={ ( v ) => setValue( v ) }
/>
<>
<NumberControl
{ ...props }
value={ value }
onChange={ ( v, extra ) => {
setValue( v );
setIsValidValue( extra.event.target.validity.valid );
} }
/>
<p>Is valid? { isValidValue ? 'Yes' : 'No' }</p>
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice way of visualising this in Storybook 👍

</>
);
}

Expand Down
80 changes: 79 additions & 1 deletion packages/components/src/number-control/test/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

/**
* WordPress dependencies
Expand Down Expand Up @@ -63,6 +63,68 @@ describe( 'NumberControl', () => {

expect( spy ).toHaveBeenCalledWith( '10' );
} );

it( 'should call onChange callback when value is clamped on blur', async () => {
const spy = jest.fn();
render(
<NumberControl
value={ 5 }
min={ 4 }
max={ 10 }
onChange={ ( v ) => spy( v ) }
/>
);

const input = getInput();
input.focus();
fireEvent.change( input, { target: { value: 1 } } );

// Before blurring, the value is still un-clamped
expect( input.value ).toBe( '1' );

input.blur();

// After blur, value is clamped
expect( input.value ).toBe( '4' );

// After the blur, the `onChange` callback fires asynchronously.
await waitFor( () => {
expect( spy ).toHaveBeenCalledTimes( 2 );
expect( spy ).toHaveBeenNthCalledWith( 1, '1' );
expect( spy ).toHaveBeenNthCalledWith( 2, 4 );
} );
} );

it( 'should call onChange callback when value is not valid', () => {
const spy = jest.fn();
render(
<NumberControl
value={ 5 }
min={ 1 }
max={ 10 }
onChange={ ( v, extra ) =>
spy( v, extra.event.target.validity.valid )
}
/>
);

const input = getInput();
input.focus();
fireEvent.change( input, { target: { value: 14 } } );

expect( input.value ).toBe( '14' );

fireKeyDown( { keyCode: ENTER } );

expect( input.value ).toBe( '10' );

expect( spy ).toHaveBeenCalledTimes( 2 );

// First call: invalid, unclamped value
expect( spy ).toHaveBeenNthCalledWith( 1, '14', false );
// Second call: valid, clamped value
expect( spy ).toHaveBeenNthCalledWith( 2, 10, true );
} );
} );

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

it( 'should clamp value within range on blur', () => {
render( <NumberControl value={ 5 } min={ 0 } max={ 10 } /> );

const input = getInput();
input.focus();
fireEvent.change( input, { target: { value: 41 } } );

// Before blurring, the value is still un-clamped
expect( input.value ).toBe( '41' );

input.blur();

// After blur, value is clamped
expect( input.value ).toBe( '10' );
} );

it( 'should parse to number value on ENTER keypress when required', () => {
render( <NumberControl value={ 5 } required={ true } /> );

Expand Down
17 changes: 11 additions & 6 deletions packages/components/src/unit-control/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ForwardedRef,
SyntheticEvent,
ChangeEvent,
PointerEvent,
} from 'react';
import { omit } from 'lodash';
import classnames from 'classnames';
Expand Down Expand Up @@ -45,7 +46,7 @@ function UnforwardedUnitControl(
isResetValueOnUnitChange = false,
isUnitSelectTabbable = true,
label,
onChange,
onChange: onChangeProp,
onUnitChange,
size = 'default',
style,
Expand Down Expand Up @@ -89,14 +90,18 @@ function UnforwardedUnitControl(

const handleOnQuantityChange = (
nextQuantityValue: number | string | undefined,
changeProps: { event: ChangeEvent< HTMLInputElement > }
changeProps: {
event:
| ChangeEvent< HTMLInputElement >
| PointerEvent< HTMLInputElement >;
}
) => {
if (
nextQuantityValue === '' ||
typeof nextQuantityValue === 'undefined' ||
nextQuantityValue === null
) {
onChange?.( '', changeProps );
onChangeProp?.( '', changeProps );
return;
}

Expand All @@ -111,7 +116,7 @@ function UnforwardedUnitControl(
unit
).join( '' );

onChange?.( onChangeValue, changeProps );
onChangeProp?.( onChangeValue, changeProps );
};

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

onChange?.( nextValue, changeProps );
onChangeProp?.( nextValue, changeProps );
onUnitChange?.( nextUnitValue, changeProps );

setUnit( nextUnitValue );
Expand Down Expand Up @@ -155,7 +160,7 @@ function UnforwardedUnitControl(
: undefined;
const changeProps = { event, data };

onChange?.(
onChangeProp?.(
`${ validParsedQuantity ?? '' }${ validParsedUnit }`,
changeProps
);
Expand Down