Skip to content
Closed
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fix

- Fix `InputControl` blocking undo/redo while focused. ([#40518](https://github.com/WordPress/gutenberg/pull/40518))

### Enhancements

- `BorderControl` now only displays the reset button in its popover when selections have already been made. ([#40917](https://github.com/WordPress/gutenberg/pull/40917))
Expand Down
19 changes: 10 additions & 9 deletions packages/components/src/color-picker/hex-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Spacer } from '../spacer';
import { space } from '../ui/utils/space';
import { ColorHexInputControl } from './styles';
import { COLORS } from '../utils/colors-values';
import { useDraft } from './use-draft';

interface HexInputProps {
color: Colord;
Expand All @@ -24,14 +25,15 @@ interface HexInputProps {
}

export const HexInput = ( { color, onChange, enableAlpha }: HexInputProps ) => {
const handleChange = ( nextValue: string | undefined ) => {
if ( ! nextValue ) return;
const hexValue = nextValue.startsWith( '#' )
? nextValue
: '#' + nextValue;
const formattedValue = color.toHex().slice( 1 ).toUpperCase();

onChange( colord( hexValue ) );
};
const draftHookProps = useDraft( {
value: formattedValue,
onChange: ( nextValue ) => {
nextValue = nextValue.replace( /^#/, '' );
onChange( colord( '#' + nextValue ) );
},
} );

return (
<ColorHexInputControl
Expand All @@ -45,8 +47,7 @@ export const HexInput = ( { color, onChange, enableAlpha }: HexInputProps ) => {
#
</Spacer>
}
value={ color.toHex().slice( 1 ).toUpperCase() }
onChange={ handleChange }
{ ...draftHookProps }
maxLength={ enableAlpha ? 9 : 7 }
label={ __( 'Hex color' ) }
hideLabelFromVision
Expand Down
9 changes: 7 additions & 2 deletions packages/components/src/color-picker/input-with-slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Spacer } from '../spacer';
import { space } from '../ui/utils/space';
import { RangeControl, NumberControlWrapper } from './styles';
import { COLORS } from '../utils/colors-values';
import { useDraft } from './use-draft';

interface InputWithSliderProps {
min: number;
Expand All @@ -25,15 +26,19 @@ export const InputWithSlider = ( {
onChange,
value,
}: InputWithSliderProps ) => {
const draftHookProps = useDraft( {
value: `${ value }`,
onChange: ( nextValue ) => onChange( parseFloat( nextValue ) ),
} );

return (
<Spacer as={ HStack } spacing={ 4 }>
<NumberControlWrapper
min={ min }
max={ max }
label={ label }
hideLabelFromVision
value={ value }
onChange={ onChange }
{ ...draftHookProps }
prefix={
<Spacer
as={ Text }
Expand Down
52 changes: 52 additions & 0 deletions packages/components/src/color-picker/use-draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* External dependencies
*/
import type { FocusEventHandler } from 'react';

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

type DraftHookProps = {
value: string;
onBlur?: FocusEventHandler;
onChange: ( next: string ) => void;
};

type DraftState = {
value?: string;
isStale?: boolean;
};

export const useDraft = ( props: DraftHookProps ) => {
const refPreviousValue = useRef( props.value );
const [ draft, setDraft ] = useState< DraftState >( {} );
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.
useEffect( () => {
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 = ( nextValue: string ) => {
// Mutates the draft value to avoid an extra render and effect run.
setDraft( ( current ) =>
Object.assign( current, { value: nextValue, isStale: false } )
);
props.onChange( nextValue );
};
const onBlur: FocusEventHandler = ( event ) => {
setDraft( {} );
props.onBlur?.( event );
};

return { value, onBlur, onChange };
};
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
2 changes: 1 addition & 1 deletion packages/components/src/input-control/reducer/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const PRESS_UP = 'PRESS_UP';
export const RESET = 'RESET';

interface EventPayload {
event?: SyntheticEvent;
event: SyntheticEvent;
}

interface Action< Type, ExtraPayload = {} > {
Expand Down
82 changes: 53 additions & 29 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 @@ -51,6 +52,14 @@ function inputControlStateReducer(
composedStateReducers: StateReducer
): StateReducer {
return ( state, action ) => {
// Updates state and returns early when there's no action type. These
// are controlled updates and need no exposure to additional reducers.
if ( ! ( 'type' in action ) ) {
return {
...state,
value: `${ action.value ?? '' }`,
};
}
const nextState = { ...state };

switch ( action.type ) {
Expand Down Expand Up @@ -97,7 +106,7 @@ function inputControlStateReducer(
case actions.RESET:
nextState.error = null;
nextState.isDirty = false;
nextState.value = action.payload.value || state.initialValue;
nextState.value = action.payload.value ?? state.initialValue;
break;

/**
Expand All @@ -108,10 +117,6 @@ function inputControlStateReducer(
break;
}

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

/**
* Send the nextState + action to the composedReducers via
* this "bridge" mechanism. This allows external stateReducers
Expand All @@ -131,13 +136,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 +155,7 @@ 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();
}

refEvent.current = event;
dispatch( {
type,
payload: { value: nextValue, event },
Expand All @@ -166,30 +165,25 @@ 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();
}

refEvent.current = event;
dispatch( { type, payload: { event } } );
};

const createDragEvent = ( type: actions.DragEventAction[ 'type' ] ) => (
payload: actions.DragEventAction[ 'payload' ]
) => {
refEvent.current = payload.event;
dispatch( { type, payload } );
};

/**
* Actions for the reducer
*/
const change = createChangeEvent( actions.CHANGE );
const invalidate = ( error: unknown, event: SyntheticEvent ) =>
const invalidate = ( error: unknown, event: SyntheticEvent ) => {
refEvent.current = event;
dispatch( { type: actions.INVALIDATE, payload: { error, event } } );
};
const reset = createChangeEvent( actions.RESET );
const commit = createChangeEvent( actions.COMMIT );

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

const currentState = useRef( state );
const currentValueProp = useRef( initialState.value );
const refEvent = useRef< SyntheticEvent | null >( null );
useLayoutEffect( () => {
currentState.current = state;
currentValueProp.current = initialState.value;
} );
useLayoutEffect( () => {
if (
refEvent.current &&
state.value !== currentValueProp.current &&
! currentState.current.isDirty
) {
onChangeHandler( state.value ?? '', {
event: refEvent.current as
| ChangeEvent< HTMLInputElement >
| PointerEvent< HTMLInputElement >,
} );
}
}, [ state.value ] );
useLayoutEffect( () => {
if (
initialState.value !== currentState.current.value &&
! currentState.current.isDirty
) {
dispatch( { value: initialState.value } );
}
if ( refEvent.current ) refEvent.current = null;
}, [ initialState.value ] );

return {
change,
commit,
Expand Down
12 changes: 7 additions & 5 deletions packages/components/src/input-control/reducer/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,24 @@ import type { Reducer } from 'react';
import type { InputAction } from './actions';

export interface InputState {
_event: Event | {};
error: unknown;
initialValue?: string;
initialValue: string;
isDirty: boolean;
isDragEnabled: boolean;
isDragging: boolean;
isPressEnterToChange: boolean;
value?: string;
value: string;
}

export type StateReducer = Reducer< InputState, InputAction >;
export type StateReducer = Reducer<
InputState,
InputAction | Partial< InputState >
>;
export type SecondaryReducer = Reducer< InputState, InputAction >;

export const initialStateReducer: StateReducer = ( state: InputState ) => state;

export const initialInputControlState: InputState = {
_event: {},
error: null,
initialValue: '',
isDirty: false,
Expand Down
Loading