initial commit
This commit is contained in:
@ -0,0 +1,2 @@
|
||||
export { default as TextInput } from './text-input';
|
||||
export { default as ValidatedTextInput } from './validated-text-input';
|
@ -0,0 +1,116 @@
|
||||
.wc-block-components-form .wc-block-components-text-input,
|
||||
.wc-block-components-text-input {
|
||||
position: relative;
|
||||
margin-top: em($gap-large);
|
||||
white-space: nowrap;
|
||||
|
||||
label {
|
||||
@include reset-typography();
|
||||
@include font-size(regular);
|
||||
position: absolute;
|
||||
transform: translateY(0.75em);
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: top left;
|
||||
line-height: 1.375; // =22px when font-size is 16px.
|
||||
color: $gray-700;
|
||||
transition: transform 200ms ease;
|
||||
margin: 0 0 0 #{$gap + 1px};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100% - #{2 * $gap});
|
||||
cursor: text;
|
||||
|
||||
.has-dark-controls & {
|
||||
color: $input-placeholder-dark;
|
||||
}
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
input:-webkit-autofill + label {
|
||||
transform: translateY(#{$gap-smallest}) scale(0.75);
|
||||
}
|
||||
|
||||
&.is-active label {
|
||||
transform: translateY(#{$gap-smallest}) scale(0.75);
|
||||
}
|
||||
|
||||
input[type="tel"],
|
||||
input[type="url"],
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"] {
|
||||
@include font-size(regular);
|
||||
background-color: #fff;
|
||||
padding: em($gap-small) $gap;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $input-border-gray;
|
||||
width: 100%;
|
||||
line-height: 1.375; // =22px when font-size is 16px.
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
height: 3em;
|
||||
min-height: 0;
|
||||
color: $input-text-active;
|
||||
|
||||
&:focus {
|
||||
background-color: #fff;
|
||||
color: $input-text-active;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $input-border-gray;
|
||||
}
|
||||
|
||||
.has-dark-controls & {
|
||||
background-color: $input-background-dark;
|
||||
border-color: $input-border-dark;
|
||||
color: $input-text-dark;
|
||||
|
||||
&:focus {
|
||||
background-color: $input-background-dark;
|
||||
color: $input-text-dark;
|
||||
box-shadow: 0 0 0 1px $input-border-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active input[type="tel"],
|
||||
&.is-active input[type="url"],
|
||||
&.is-active input[type="text"],
|
||||
&.is-active input[type="number"],
|
||||
&.is-active input[type="email"] {
|
||||
padding: em($gap-large) 0 em($gap-smallest) $gap;
|
||||
}
|
||||
|
||||
&.has-error input {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: $alert-red;
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px $alert-red;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-error label {
|
||||
color: $alert-red;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useState } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Label from '../label';
|
||||
import './style.scss';
|
||||
|
||||
interface TextInputProps
|
||||
extends Omit<
|
||||
InputHTMLAttributes< HTMLInputElement >,
|
||||
'onChange' | 'onBlur'
|
||||
> {
|
||||
id: string;
|
||||
ariaLabel?: string;
|
||||
label?: string;
|
||||
ariaDescribedBy?: string;
|
||||
screenReaderLabel?: string;
|
||||
help?: string;
|
||||
feedback?: boolean | JSX.Element;
|
||||
autoComplete?: string;
|
||||
onChange: ( newValue: string ) => void;
|
||||
onBlur?: ( newValue: string ) => void;
|
||||
}
|
||||
|
||||
const TextInput = forwardRef< HTMLInputElement, TextInputProps >(
|
||||
(
|
||||
{
|
||||
className,
|
||||
id,
|
||||
type = 'text',
|
||||
ariaLabel,
|
||||
ariaDescribedBy,
|
||||
label,
|
||||
screenReaderLabel,
|
||||
disabled,
|
||||
help,
|
||||
autoCapitalize = 'off',
|
||||
autoComplete = 'off',
|
||||
value = '',
|
||||
onChange,
|
||||
required = false,
|
||||
onBlur = () => {
|
||||
/* Do nothing */
|
||||
},
|
||||
feedback,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [ isActive, setIsActive ] = useState( false );
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
'wc-block-components-text-input',
|
||||
className,
|
||||
{
|
||||
'is-active': isActive || value,
|
||||
}
|
||||
) }
|
||||
>
|
||||
<input
|
||||
type={ type }
|
||||
id={ id }
|
||||
value={ value }
|
||||
ref={ ref }
|
||||
autoCapitalize={ autoCapitalize }
|
||||
autoComplete={ autoComplete }
|
||||
onChange={ ( event ) => {
|
||||
onChange( event.target.value );
|
||||
} }
|
||||
onFocus={ () => setIsActive( true ) }
|
||||
onBlur={ ( event ) => {
|
||||
onBlur( event.target.value );
|
||||
setIsActive( false );
|
||||
} }
|
||||
aria-label={ ariaLabel || label }
|
||||
disabled={ disabled }
|
||||
aria-describedby={
|
||||
!! help && ! ariaDescribedBy
|
||||
? id + '__help'
|
||||
: ariaDescribedBy
|
||||
}
|
||||
required={ required }
|
||||
{ ...rest }
|
||||
/>
|
||||
<Label
|
||||
label={ label }
|
||||
screenReaderLabel={ screenReaderLabel || label }
|
||||
wrapperElement="label"
|
||||
wrapperProps={ {
|
||||
htmlFor: id,
|
||||
} }
|
||||
htmlFor={ id }
|
||||
/>
|
||||
{ !! help && (
|
||||
<p
|
||||
id={ id + '__help' }
|
||||
className="wc-block-components-text-input__help"
|
||||
>
|
||||
{ help }
|
||||
</p>
|
||||
) }
|
||||
{ feedback }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TextInput;
|
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
ValidationInputError,
|
||||
useValidationContext,
|
||||
useCheckoutContext,
|
||||
} from '@woocommerce/base-context';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import TextInput from './text-input';
|
||||
import './style.scss';
|
||||
|
||||
interface ValidatedTextInputPropsWithId {
|
||||
instanceId?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ValidatedTextInputPropsWithInstanceId {
|
||||
instanceId: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
type ValidatedTextInputProps = (
|
||||
| ValidatedTextInputPropsWithId
|
||||
| ValidatedTextInputPropsWithInstanceId
|
||||
) & {
|
||||
className?: string;
|
||||
ariaDescribedBy?: string;
|
||||
errorId?: string;
|
||||
validateOnMount?: boolean;
|
||||
focusOnMount?: boolean;
|
||||
showError?: boolean;
|
||||
errorMessage?: string;
|
||||
onChange: ( newValue: string ) => void;
|
||||
};
|
||||
|
||||
const ValidatedTextInput = ( {
|
||||
className,
|
||||
instanceId,
|
||||
id,
|
||||
ariaDescribedBy,
|
||||
errorId,
|
||||
validateOnMount = true,
|
||||
focusOnMount = false,
|
||||
onChange,
|
||||
showError = true,
|
||||
errorMessage: passedErrorMessage = '',
|
||||
...rest
|
||||
}: ValidatedTextInputProps ) => {
|
||||
const [ isPristine, setIsPristine ] = useState( true );
|
||||
const inputRef = useRef< HTMLInputElement >( null );
|
||||
const {
|
||||
getValidationError,
|
||||
hideValidationError,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
getValidationErrorId,
|
||||
} = useValidationContext();
|
||||
|
||||
const { isBeforeProcessing } = useCheckoutContext();
|
||||
|
||||
const textInputId =
|
||||
typeof id !== 'undefined' ? id : 'textinput-' + instanceId;
|
||||
const errorIdString = errorId !== undefined ? errorId : textInputId;
|
||||
|
||||
const validateInput = useCallback(
|
||||
( errorsHidden = true ) => {
|
||||
const inputObject = inputRef.current || null;
|
||||
if ( ! inputObject ) {
|
||||
return;
|
||||
}
|
||||
// Trim white space before validation.
|
||||
inputObject.value = inputObject.value.trim();
|
||||
const inputIsValid = inputObject.checkValidity();
|
||||
if ( inputIsValid ) {
|
||||
clearValidationError( errorIdString );
|
||||
} else {
|
||||
setValidationErrors( {
|
||||
[ errorIdString ]: {
|
||||
message:
|
||||
inputObject.validationMessage ||
|
||||
__(
|
||||
'Invalid value.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
hidden: errorsHidden,
|
||||
},
|
||||
} );
|
||||
}
|
||||
},
|
||||
[ clearValidationError, errorIdString, setValidationErrors ]
|
||||
);
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPristine ) {
|
||||
if ( focusOnMount ) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
setIsPristine( false );
|
||||
}
|
||||
}, [ focusOnMount, isPristine, setIsPristine ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isPristine ) {
|
||||
if ( validateOnMount ) {
|
||||
validateInput();
|
||||
}
|
||||
setIsPristine( false );
|
||||
}
|
||||
}, [ isPristine, setIsPristine, validateOnMount, validateInput ] );
|
||||
|
||||
/**
|
||||
* @todo Remove extra validation call after refactoring the validation system.
|
||||
*/
|
||||
useEffect( () => {
|
||||
if ( isBeforeProcessing ) {
|
||||
validateInput();
|
||||
}
|
||||
}, [ isBeforeProcessing, validateInput ] );
|
||||
// Remove validation errors when unmounted.
|
||||
useEffect( () => {
|
||||
return () => {
|
||||
clearValidationError( errorIdString );
|
||||
};
|
||||
}, [ clearValidationError, errorIdString ] );
|
||||
|
||||
// @todo - When useValidationContext is converted to TypeScript, remove this cast and use the correct type.
|
||||
const errorMessage = ( getValidationError( errorIdString ) || {} ) as {
|
||||
message?: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
if ( isString( passedErrorMessage ) && passedErrorMessage !== '' ) {
|
||||
errorMessage.message = passedErrorMessage;
|
||||
}
|
||||
const hasError = errorMessage.message && ! errorMessage.hidden;
|
||||
const describedBy =
|
||||
showError && hasError && getValidationErrorId( errorIdString )
|
||||
? getValidationErrorId( errorIdString )
|
||||
: ariaDescribedBy;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
className={ classnames( className, {
|
||||
'has-error': hasError,
|
||||
} ) }
|
||||
aria-invalid={ hasError === true }
|
||||
id={ textInputId }
|
||||
onBlur={ () => {
|
||||
validateInput( false );
|
||||
} }
|
||||
feedback={
|
||||
showError && (
|
||||
<ValidationInputError
|
||||
errorMessage={ passedErrorMessage }
|
||||
propertyName={ errorIdString }
|
||||
/>
|
||||
)
|
||||
}
|
||||
ref={ inputRef }
|
||||
onChange={ ( val ) => {
|
||||
hideValidationError( errorIdString );
|
||||
onChange( val );
|
||||
} }
|
||||
ariaDescribedBy={ describedBy }
|
||||
{ ...rest }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default withInstanceId( ValidatedTextInput );
|
Reference in New Issue
Block a user