diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index b538beecb144d..28a64f2fb37c1 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -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 diff --git a/packages/components/src/input-control/index.tsx b/packages/components/src/input-control/index.tsx index 6ff965f9ab131..c3f4de5396a02 100644 --- a/packages/components/src/input-control/index.tsx +++ b/packages/components/src/input-control/index.tsx @@ -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 ); @@ -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 ( ); diff --git a/packages/components/src/input-control/input-field.tsx b/packages/components/src/input-control/input-field.tsx index 999f1d3b2d6a9..744f7f93c79dc 100644 --- a/packages/components/src/input-control/input-field.tsx +++ b/packages/components/src/input-control/input-field.tsx @@ -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( @@ -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 ); diff --git a/packages/components/src/input-control/reducer/reducer.ts b/packages/components/src/input-control/reducer/reducer.ts index de13dd9a8f950..c02b44eb9175e 100644 --- a/packages/components/src/input-control/reducer/reducer.ts +++ b/packages/components/src/input-control/reducer/reducer.ts @@ -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 @@ -18,6 +18,7 @@ import { initialStateReducer, } from './state'; import * as actions from './actions'; +import type { InputChangeCallback } from '../types'; /** * Prepares initialState for the reducer. @@ -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 @@ -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 ), @@ -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 }, @@ -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 } } ); }; @@ -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, diff --git a/packages/components/src/input-control/reducer/state.ts b/packages/components/src/input-control/reducer/state.ts index be7dd3547300b..2ca9edaa27b20 100644 --- a/packages/components/src/input-control/reducer/state.ts +++ b/packages/components/src/input-control/reducer/state.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { Reducer } from 'react'; +import type { Reducer, SyntheticEvent } from 'react'; /** * Internal dependencies @@ -9,7 +9,7 @@ import type { Reducer } from 'react'; import type { InputAction } from './actions'; export interface InputState { - _event: Event | {}; + _event?: SyntheticEvent; error: unknown; initialValue?: string; isDirty: boolean; @@ -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, diff --git a/packages/components/src/input-control/utils.ts b/packages/components/src/input-control/utils.ts index 3c755de9b7c17..b051cb8b4dd5e 100644 --- a/packages/components/src/input-control/utils.ts +++ b/packages/components/src/input-control/utils.ts @@ -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. @@ -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 }; +}