2020-03-23 11:22:00 +00:00
|
|
|
/**
|
|
|
|
* External dependencies
|
|
|
|
*/
|
2023-08-09 17:24:51 +00:00
|
|
|
import {
|
|
|
|
useEffect,
|
|
|
|
useState,
|
|
|
|
useCallback,
|
|
|
|
forwardRef,
|
|
|
|
useImperativeHandle,
|
|
|
|
useRef,
|
|
|
|
} from '@wordpress/element';
|
2024-05-31 03:49:36 +00:00
|
|
|
import clsx from 'clsx';
|
2022-12-06 13:13:21 +00:00
|
|
|
import { isObject } from '@woocommerce/types';
|
2022-11-10 10:05:41 +00:00
|
|
|
import { useDispatch, useSelect } from '@wordpress/data';
|
2022-07-01 23:06:25 +00:00
|
|
|
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
2022-12-06 13:13:21 +00:00
|
|
|
import { usePrevious } from '@woocommerce/base-hooks';
|
2023-08-09 17:24:51 +00:00
|
|
|
import { useInstanceId } from '@wordpress/compose';
|
2020-03-23 11:22:00 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Internal dependencies
|
|
|
|
*/
|
2021-01-19 15:55:44 +00:00
|
|
|
import TextInput from './text-input';
|
2020-03-23 11:22:00 +00:00
|
|
|
import './style.scss';
|
2022-07-01 23:06:25 +00:00
|
|
|
import { ValidationInputError } from '../validation-input-error';
|
2023-11-14 14:52:14 +00:00
|
|
|
import { getValidityMessageForInput } from '../../checkout/utils';
|
2023-08-03 11:02:20 +00:00
|
|
|
import { ValidatedTextInputProps } from './types';
|
2020-03-23 11:22:00 +00:00
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
export type ValidatedTextInputHandle = {
|
2024-08-23 18:22:28 +00:00
|
|
|
focus?: () => void;
|
2023-08-09 17:24:51 +00:00
|
|
|
revalidate: () => void;
|
|
|
|
};
|
|
|
|
|
2023-08-03 11:02:20 +00:00
|
|
|
/**
|
|
|
|
* A text based input which validates the input value.
|
|
|
|
*/
|
2023-08-09 17:24:51 +00:00
|
|
|
const ValidatedTextInput = forwardRef<
|
|
|
|
ValidatedTextInputHandle,
|
|
|
|
ValidatedTextInputProps
|
|
|
|
>(
|
|
|
|
(
|
|
|
|
{
|
|
|
|
className,
|
|
|
|
id,
|
2023-11-14 18:30:23 +00:00
|
|
|
type = 'text',
|
2023-08-09 17:24:51 +00:00
|
|
|
ariaDescribedBy,
|
|
|
|
errorId,
|
|
|
|
focusOnMount = false,
|
|
|
|
onChange,
|
|
|
|
showError = true,
|
|
|
|
errorMessage: passedErrorMessage = '',
|
|
|
|
value = '',
|
|
|
|
customValidation = () => true,
|
2024-07-15 10:43:02 +00:00
|
|
|
customValidityMessage,
|
2024-07-15 15:22:42 +00:00
|
|
|
feedback = null,
|
2023-08-09 17:24:51 +00:00
|
|
|
customFormatter = ( newValue: string ) => newValue,
|
|
|
|
label,
|
|
|
|
validateOnMount = true,
|
2023-11-09 16:25:28 +00:00
|
|
|
instanceId: preferredInstanceId = '',
|
2023-08-09 17:24:51 +00:00
|
|
|
...rest
|
|
|
|
},
|
|
|
|
forwardedRef
|
|
|
|
): JSX.Element => {
|
|
|
|
// True on mount.
|
|
|
|
const [ isPristine, setIsPristine ] = useState( true );
|
|
|
|
|
|
|
|
// Track incoming value.
|
|
|
|
const previousValue = usePrevious( value );
|
|
|
|
|
|
|
|
// Ref for the input element.
|
|
|
|
const inputRef = useRef< HTMLInputElement >( null );
|
|
|
|
|
|
|
|
const instanceId = useInstanceId(
|
|
|
|
ValidatedTextInput,
|
|
|
|
'',
|
|
|
|
preferredInstanceId
|
|
|
|
);
|
|
|
|
const textInputId =
|
|
|
|
typeof id !== 'undefined' ? id : 'textinput-' + instanceId;
|
|
|
|
const errorIdString = errorId !== undefined ? errorId : textInputId;
|
|
|
|
|
|
|
|
const {
|
|
|
|
setValidationErrors,
|
|
|
|
hideValidationError,
|
|
|
|
clearValidationError,
|
|
|
|
} = useDispatch( VALIDATION_STORE_KEY );
|
|
|
|
|
2023-11-09 16:25:28 +00:00
|
|
|
// Ref for validation callback.
|
|
|
|
const customValidationRef = useRef( customValidation );
|
|
|
|
|
|
|
|
// Update ref when validation callback changes.
|
|
|
|
useEffect( () => {
|
|
|
|
customValidationRef.current = customValidation;
|
|
|
|
}, [ customValidation ] );
|
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
const { validationError, validationErrorId } = useSelect(
|
|
|
|
( select ) => {
|
|
|
|
const store = select( VALIDATION_STORE_KEY );
|
|
|
|
return {
|
|
|
|
validationError: store.getValidationError( errorIdString ),
|
|
|
|
validationErrorId:
|
|
|
|
store.getValidationErrorId( errorIdString ),
|
|
|
|
};
|
2020-12-17 14:52:44 +00:00
|
|
|
}
|
2023-08-09 17:24:51 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
const validateInput = useCallback(
|
|
|
|
( errorsHidden = true ) => {
|
|
|
|
const inputObject = inputRef.current || null;
|
|
|
|
|
|
|
|
if ( inputObject === null ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Trim white space before validation.
|
|
|
|
inputObject.value = inputObject.value.trim();
|
|
|
|
inputObject.setCustomValidity( '' );
|
|
|
|
|
|
|
|
if (
|
|
|
|
inputObject.checkValidity() &&
|
2023-11-09 16:25:28 +00:00
|
|
|
customValidationRef.current( inputObject )
|
2023-08-09 17:24:51 +00:00
|
|
|
) {
|
|
|
|
clearValidationError( errorIdString );
|
|
|
|
return;
|
|
|
|
}
|
2022-12-06 13:13:21 +00:00
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
setValidationErrors( {
|
|
|
|
[ errorIdString ]: {
|
2024-07-15 10:43:02 +00:00
|
|
|
message: getValidityMessageForInput(
|
|
|
|
label,
|
|
|
|
inputObject,
|
|
|
|
customValidityMessage
|
|
|
|
),
|
2023-08-09 17:24:51 +00:00
|
|
|
hidden: errorsHidden,
|
|
|
|
},
|
|
|
|
} );
|
|
|
|
},
|
2024-07-15 10:43:02 +00:00
|
|
|
[
|
|
|
|
clearValidationError,
|
|
|
|
errorIdString,
|
|
|
|
setValidationErrors,
|
|
|
|
label,
|
|
|
|
customValidityMessage,
|
|
|
|
]
|
2023-08-09 17:24:51 +00:00
|
|
|
);
|
2022-12-06 13:13:21 +00:00
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
// Allows parent to trigger revalidation.
|
|
|
|
useImperativeHandle(
|
|
|
|
forwardedRef,
|
|
|
|
function () {
|
|
|
|
return {
|
2024-08-23 18:22:28 +00:00
|
|
|
focus() {
|
|
|
|
inputRef.current?.focus();
|
|
|
|
},
|
2023-08-09 17:24:51 +00:00
|
|
|
revalidate() {
|
|
|
|
validateInput( ! value );
|
|
|
|
},
|
|
|
|
};
|
|
|
|
},
|
|
|
|
[ validateInput, value ]
|
|
|
|
);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handle browser autofill / changes via data store.
|
|
|
|
*
|
|
|
|
* Trigger validation on incoming state change if the current element is not in focus. This is because autofilled
|
|
|
|
* elements do not trigger the blur() event, and so values can be validated in the background if the state changes
|
|
|
|
* elsewhere.
|
|
|
|
*
|
|
|
|
* Errors are immediately visible.
|
|
|
|
*/
|
|
|
|
useEffect( () => {
|
2023-08-03 11:02:20 +00:00
|
|
|
if (
|
2023-08-09 17:24:51 +00:00
|
|
|
value !== previousValue &&
|
|
|
|
( value || previousValue ) &&
|
|
|
|
inputRef &&
|
|
|
|
inputRef.current !== null &&
|
|
|
|
inputRef.current?.ownerDocument?.activeElement !==
|
|
|
|
inputRef.current
|
2023-08-03 11:02:20 +00:00
|
|
|
) {
|
2023-08-09 17:24:51 +00:00
|
|
|
const formattedValue = customFormatter(
|
|
|
|
inputRef.current.value
|
|
|
|
);
|
|
|
|
|
|
|
|
if ( formattedValue !== value ) {
|
|
|
|
onChange( formattedValue );
|
2023-10-05 09:27:10 +00:00
|
|
|
} else {
|
|
|
|
validateInput( true );
|
2023-08-09 17:24:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [ validateInput, customFormatter, value, previousValue, onChange ] );
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Validation on mount.
|
|
|
|
*
|
|
|
|
* If the input is in pristine state on mount, focus the element (if focusOnMount is enabled), and validate in the
|
|
|
|
* background.
|
|
|
|
*
|
|
|
|
* Errors are hidden until blur.
|
|
|
|
*/
|
|
|
|
useEffect( () => {
|
|
|
|
if ( ! isPristine ) {
|
2022-12-06 13:13:21 +00:00
|
|
|
return;
|
|
|
|
}
|
2023-11-09 16:25:28 +00:00
|
|
|
|
|
|
|
setIsPristine( false );
|
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
if ( focusOnMount ) {
|
|
|
|
inputRef.current?.focus();
|
|
|
|
}
|
2022-12-06 13:13:21 +00:00
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
// if validateOnMount is false, only validate input if focusOnMount is also false
|
|
|
|
if ( validateOnMount || ! focusOnMount ) {
|
|
|
|
validateInput( true );
|
|
|
|
}
|
|
|
|
}, [
|
|
|
|
validateOnMount,
|
|
|
|
focusOnMount,
|
|
|
|
isPristine,
|
|
|
|
setIsPristine,
|
|
|
|
validateInput,
|
|
|
|
] );
|
|
|
|
|
|
|
|
// Remove validation errors when unmounted.
|
|
|
|
useEffect( () => {
|
|
|
|
return () => {
|
|
|
|
clearValidationError( errorIdString );
|
|
|
|
};
|
|
|
|
}, [ clearValidationError, errorIdString ] );
|
|
|
|
|
|
|
|
if ( passedErrorMessage !== '' && isObject( validationError ) ) {
|
|
|
|
validationError.message = passedErrorMessage;
|
2023-04-11 08:50:59 +00:00
|
|
|
}
|
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
const hasError = validationError?.message && ! validationError?.hidden;
|
|
|
|
const describedBy =
|
|
|
|
showError && hasError && validationErrorId
|
|
|
|
? validationErrorId
|
|
|
|
: ariaDescribedBy;
|
2021-12-13 16:44:28 +00:00
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
return (
|
|
|
|
<TextInput
|
2024-05-31 03:49:36 +00:00
|
|
|
className={ clsx( className, {
|
2023-08-09 17:24:51 +00:00
|
|
|
'has-error': hasError,
|
|
|
|
} ) }
|
|
|
|
aria-invalid={ hasError === true }
|
|
|
|
id={ textInputId }
|
2023-11-14 18:30:23 +00:00
|
|
|
type={ type }
|
2023-08-09 17:24:51 +00:00
|
|
|
feedback={
|
2024-07-15 15:22:42 +00:00
|
|
|
showError && hasError ? (
|
2023-08-09 17:24:51 +00:00
|
|
|
<ValidationInputError
|
|
|
|
errorMessage={ passedErrorMessage }
|
|
|
|
propertyName={ errorIdString }
|
|
|
|
/>
|
2024-07-15 15:22:42 +00:00
|
|
|
) : (
|
|
|
|
feedback
|
|
|
|
)
|
2023-08-09 17:24:51 +00:00
|
|
|
}
|
|
|
|
ref={ inputRef }
|
|
|
|
onChange={ ( newValue ) => {
|
|
|
|
// Hide errors while typing.
|
|
|
|
hideValidationError( errorIdString );
|
2022-12-06 13:13:21 +00:00
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
// Validate the input value.
|
|
|
|
validateInput( true );
|
2022-12-06 13:13:21 +00:00
|
|
|
|
2023-08-09 17:24:51 +00:00
|
|
|
// Push the changes up to the parent component.
|
|
|
|
const formattedValue = customFormatter( newValue );
|
|
|
|
|
|
|
|
if ( formattedValue !== value ) {
|
|
|
|
onChange( formattedValue );
|
|
|
|
}
|
|
|
|
} }
|
|
|
|
onBlur={ () => validateInput( false ) }
|
|
|
|
ariaDescribedBy={ describedBy }
|
|
|
|
value={ value }
|
2023-11-14 18:30:23 +00:00
|
|
|
title="" // This prevents the same error being shown on hover.
|
2023-08-09 17:24:51 +00:00
|
|
|
label={ label }
|
|
|
|
{ ...rest }
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
export default ValidatedTextInput;
|