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

InputControl: Fix undo when changing padding values #40518

Merged
merged 11 commits into from
May 30, 2022
Merged
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 };
}