Skip to content

Commit

Permalink
InputControl: Fix undo when changing padding values (#40518)
Browse files Browse the repository at this point in the history
* Fix undo when changing padding values

* Add changelog entry

* Have `InputControl` favor entered value for a render cycle

* Run propagation effect when `isDirty` changes

* Use event existence to eliminate extraneous `onChange` calls

* Keep change handler in a ref to pass `react-hooks/exhaustive-deps`

* Cleanup legacy `event.persist` calls

Co-authored-by: Mitchell Austin <mr.fye@oneandthesame.net>
  • Loading branch information
youknowriad and stokesman authored May 30, 2022
1 parent 4f0f98e commit d5cc0dc
Show file tree
Hide file tree
Showing 6 changed files with 116 additions and 62 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Bug Fix

- `Popover`, `Dropdown`, `CustomGradientPicker`: Fix dropdown positioning by always targeting the rendered toggle, and switch off width in the Popover size middleware to stop reducing the width of the popover. ([#41361](https://github.com/WordPress/gutenberg/pull/41361))
- Fix `InputControl` blocking undo/redo while focused. ([#40518](https://github.com/WordPress/gutenberg/pull/40518))

### Enhancements

Expand Down
10 changes: 8 additions & 2 deletions packages/components/src/input-control/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useState, forwardRef } from '@wordpress/element';
import InputBase from './input-base';
import InputField from './input-field';
import type { InputControlProps } from './types';
import { useDraft } from './utils';

function useUniqueId( idProp?: string ) {
const instanceId = useInstanceId( InputControl );
Expand Down Expand Up @@ -52,6 +53,12 @@ export function UnforwardedInputControl(
const id = useUniqueId( idProp );
const classes = classNames( 'components-input-control', className );

const draftHookProps = useDraft( {
value,
onBlur: props.onBlur,
onChange,
} );

return (
<InputBase
__unstableInputWidth={ __unstableInputWidth }
Expand All @@ -75,14 +82,13 @@ export function UnforwardedInputControl(
id={ id }
isFocused={ isFocused }
isPressEnterToChange={ isPressEnterToChange }
onChange={ onChange }
onKeyDown={ onKeyDown }
onValidate={ onValidate }
ref={ ref }
setIsFocused={ setIsFocused }
size={ size }
stateReducer={ stateReducer }
value={ value }
{ ...draftHookProps }
/>
</InputBase>
);
Expand Down
40 changes: 10 additions & 30 deletions packages/components/src/input-control/input-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import type { WordPressComponentProps } from '../ui/context';
import { useDragCursor } from './utils';
import { Input } from './styles/input-control-styles';
import { useInputControlStateReducer } from './reducer/reducer';
import { useUpdateEffect } from '../utils';
import type { InputFieldProps } from './types';

function InputField(
Expand Down Expand Up @@ -67,40 +66,21 @@ function InputField(
pressEnter,
pressUp,
reset,
} = useInputControlStateReducer( stateReducer, {
isDragEnabled,
value: valueProp,
isPressEnterToChange,
} );
} = useInputControlStateReducer(
stateReducer,
{
isDragEnabled,
value: valueProp,
isPressEnterToChange,
},
onChange
);

const { _event, value, isDragging, isDirty } = state;
const { value, isDragging, isDirty } = state;
const wasDirtyOnBlur = useRef( false );

const dragCursor = useDragCursor( isDragging, dragDirection );

/*
* Handles synchronization of external and internal value state.
* If not focused and did not hold a dirty value[1] on blur
* updates the value from the props. Otherwise if not holding
* a dirty value[1] propagates the value and event through onChange.
* [1] value is only made dirty if isPressEnterToChange is true
*/
useUpdateEffect( () => {
if ( valueProp === value ) {
return;
}
if ( ! isFocused && ! wasDirtyOnBlur.current ) {
commit( valueProp, _event as SyntheticEvent );
} else if ( ! isDirty ) {
onChange( value, {
event: _event as
| ChangeEvent< HTMLInputElement >
| PointerEvent< HTMLInputElement >,
} );
wasDirtyOnBlur.current = false;
}
}, [ value, isDirty, isFocused, valueProp ] );

const handleOnBlur = ( event: FocusEvent< HTMLInputElement > ) => {
onBlur( event );
setIsFocused?.( false );
Expand Down
66 changes: 40 additions & 26 deletions packages/components/src/input-control/reducer/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* External dependencies
*/
import type { SyntheticEvent } from 'react';
import type { SyntheticEvent, ChangeEvent, PointerEvent } from 'react';

/**
* WordPress dependencies
*/
import { useReducer } from '@wordpress/element';
import { useReducer, useLayoutEffect, useRef } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -18,6 +18,7 @@ import {
initialStateReducer,
} from './state';
import * as actions from './actions';
import type { InputChangeCallback } from '../types';

/**
* Prepares initialState for the reducer.
Expand Down Expand Up @@ -108,9 +109,7 @@ function inputControlStateReducer(
break;
}

if ( action.payload.event ) {
nextState._event = action.payload.event;
}
nextState._event = action.payload.event;

/**
* Send the nextState + action to the composedReducers via
Expand All @@ -131,13 +130,15 @@ function inputControlStateReducer(
* This technique uses the "stateReducer" design pattern:
* https://kentcdodds.com/blog/the-state-reducer-pattern/
*
* @param stateReducer An external state reducer.
* @param initialState The initial state for the reducer.
* @param stateReducer An external state reducer.
* @param initialState The initial state for the reducer.
* @param onChangeHandler A handler for the onChange event.
* @return State, dispatch, and a collection of actions.
*/
export function useInputControlStateReducer(
stateReducer: StateReducer = initialStateReducer,
initialState: Partial< InputState > = initialInputControlState
initialState: Partial< InputState > = initialInputControlState,
onChangeHandler: InputChangeCallback
) {
const [ state, dispatch ] = useReducer< StateReducer >(
inputControlStateReducer( stateReducer ),
Expand All @@ -148,15 +149,6 @@ export function useInputControlStateReducer(
nextValue: actions.ChangeEventAction[ 'payload' ][ 'value' ],
event: actions.ChangeEventAction[ 'payload' ][ 'event' ]
) => {
/**
* Persist allows for the (Synthetic) event to be used outside of
* this function call.
* https://reactjs.org/docs/events.html#event-pooling
*/
if ( event && event.persist ) {
event.persist();
}

dispatch( {
type,
payload: { value: nextValue, event },
Expand All @@ -166,15 +158,6 @@ export function useInputControlStateReducer(
const createKeyEvent = ( type: actions.KeyEventAction[ 'type' ] ) => (
event: actions.KeyEventAction[ 'payload' ][ 'event' ]
) => {
/**
* Persist allows for the (Synthetic) event to be used outside of
* this function call.
* https://reactjs.org/docs/events.html#event-pooling
*/
if ( event && event.persist ) {
event.persist();
}

dispatch( { type, payload: { event } } );
};

Expand All @@ -201,6 +184,37 @@ export function useInputControlStateReducer(
const pressDown = createKeyEvent( actions.PRESS_DOWN );
const pressEnter = createKeyEvent( actions.PRESS_ENTER );

const currentState = useRef( state );
const refProps = useRef( { value: initialState.value, onChangeHandler } );
useLayoutEffect( () => {
currentState.current = state;
refProps.current = { value: initialState.value, onChangeHandler };
} );
useLayoutEffect( () => {
if (
currentState.current._event !== undefined &&
state.value !== refProps.current.value &&
! state.isDirty
) {
refProps.current.onChangeHandler( state.value ?? '', {
event: currentState.current._event as
| ChangeEvent< HTMLInputElement >
| PointerEvent< HTMLInputElement >,
} );
}
}, [ state.value, state.isDirty ] );
useLayoutEffect( () => {
if (
initialState.value !== currentState.current.value &&
! currentState.current.isDirty
) {
dispatch( {
type: actions.RESET,
payload: { value: initialState.value },
} );
}
}, [ initialState.value ] );

return {
change,
commit,
Expand Down
5 changes: 2 additions & 3 deletions packages/components/src/input-control/reducer/state.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* External dependencies
*/
import type { Reducer } from 'react';
import type { Reducer, SyntheticEvent } from 'react';

/**
* Internal dependencies
*/
import type { InputAction } from './actions';

export interface InputState {
_event: Event | {};
_event?: SyntheticEvent;
error: unknown;
initialValue?: string;
isDirty: boolean;
Expand All @@ -24,7 +24,6 @@ export type StateReducer = Reducer< InputState, InputAction >;
export const initialStateReducer: StateReducer = ( state: InputState ) => state;

export const initialInputControlState: InputState = {
_event: {},
error: null,
initialValue: '',
isDirty: false,
Expand Down
56 changes: 55 additions & 1 deletion packages/components/src/input-control/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
/**
* External dependencies
*/
import type { FocusEventHandler } from 'react';

/**
* WordPress dependencies
*/
import { useEffect } from '@wordpress/element';
import {
useEffect,
useLayoutEffect,
useRef,
useState,
} from '@wordpress/element';

/**
* Internal dependencies
*/
import type { InputChangeCallback } from './types';

/**
* Gets a CSS cursor value based on a drag direction.
Expand Down Expand Up @@ -52,3 +67,42 @@ export function useDragCursor(

return dragCursor;
}

export function useDraft( props: {
value: string | undefined;
onBlur?: FocusEventHandler;
onChange: InputChangeCallback;
} ) {
const refPreviousValue = useRef( props.value );
const [ draft, setDraft ] = useState< {
value?: string;
isStale?: boolean;
} >( {} );
const value = draft.value !== undefined ? draft.value : props.value;

// Determines when to discard the draft value to restore controlled status.
// To do so, it tracks the previous value and marks the draft value as stale
// after each render.
useLayoutEffect( () => {
const { current: previousValue } = refPreviousValue;
refPreviousValue.current = props.value;
if ( draft.value !== undefined && ! draft.isStale )
setDraft( { ...draft, isStale: true } );
else if ( draft.isStale && props.value !== previousValue )
setDraft( {} );
}, [ props.value, draft ] );

const onChange: InputChangeCallback = ( nextValue, extra ) => {
// Mutates the draft value to avoid an extra effect run.
setDraft( ( current ) =>
Object.assign( current, { value: nextValue, isStale: false } )
);
props.onChange( nextValue, extra );
};
const onBlur: FocusEventHandler = ( event ) => {
setDraft( {} );
props.onBlur?.( event );
};

return { value, onBlur, onChange };
}

0 comments on commit d5cc0dc

Please sign in to comment.