diff --git a/frontend/documentation/components/InputGroup.stories.tsx b/frontend/documentation/components/InputGroup.stories.tsx index b17de28635d5..53b8b6a9e216 100644 --- a/frontend/documentation/components/InputGroup.stories.tsx +++ b/frontend/documentation/components/InputGroup.stories.tsx @@ -77,6 +77,21 @@ export const MultipleErrors: Story = { ), } +// Untouched on purpose: isInvalid colours the control straight away, which is +// what a form submitted with Enter needs. isValid alone waits for a blur. +export const InvalidBeforeBlur: Story = { + render: () => ( + + ), +} + export const Disabled: Story = { render: () => , } diff --git a/frontend/web/components/base/forms/Input.tsx b/frontend/web/components/base/forms/Input.tsx index bfbbda3587cc..e2c2cd62bb3c 100644 --- a/frontend/web/components/base/forms/Input.tsx +++ b/frontend/web/components/base/forms/Input.tsx @@ -21,6 +21,8 @@ export interface InputMethods { export interface InputProps extends Omit, 'size'> { + // Show the invalid state without waiting for the field to be touched, for a + // validity known up front, e.g. the API rejected the value. autoValidate?: boolean centered?: boolean inputClassName?: string @@ -69,9 +71,7 @@ const Input: React.FC = ({ }) => { const inputRef = useRef(null) const [isFocused, setIsFocused] = useState(false) - const [shouldValidate, setShouldValidate] = useState( - !!value || !!autoValidate, - ) + const [hasBeenTouched, setHasBeenTouched] = useState(!!value) const [type, setType] = useState(typeProp) // No-op under E2E to avoid programmatic focus stealing during tests; native @@ -90,7 +90,7 @@ const Input: React.FC = ({ const onBlur = (e: FocusEvent) => { setIsFocused(false) - setShouldValidate(true) + setHasBeenTouched(true) onBlurProp?.(e) } @@ -101,7 +101,7 @@ const Input: React.FC = ({ onKeyDownProp?.(e) } - const invalid = shouldValidate && !isValid + const invalid = (hasBeenTouched || !!autoValidate) && !isValid const success = isValid && showSuccess const sizeClassName = size ? sizeClassNames[size] : '' const containerClassName = cn( diff --git a/frontend/web/components/base/forms/InputGroup.tsx b/frontend/web/components/base/forms/InputGroup.tsx index cb2c6d71a6fd..7ca99527b95e 100644 --- a/frontend/web/components/base/forms/InputGroup.tsx +++ b/frontend/web/components/base/forms/InputGroup.tsx @@ -94,6 +94,14 @@ const InputGroup: FC = ({ // the message for this field (htmlFor/id/aria-describedby all share `id`). const errorId = `${id}-error` const hasError = Array.isArray(error) ? error.length > 0 : !!error + // isInvalid is the caller stating the value is wrong, so it beats the computed + // validity and skips the wait for a blur. + let inputIsValid: boolean | undefined + if (isInvalid) { + inputIsValid = false + } else if (isValid !== null && isValid !== undefined) { + inputIsValid = !!isValid + } let errorContent: ReactNode = null if (typeof error === 'string') { errorContent = error @@ -152,11 +160,8 @@ const InputGroup: FC = ({ inputRef.current = c }} {...restInputProps} - isValid={ - isValid === null || isValid === undefined - ? undefined - : !!isValid - } + autoValidate={!!isInvalid} + isValid={inputIsValid} disabled={disabled} defaultValue={defaultValue} value={value}