From 593d696ef71a48877796f65fbb2947ea039a7ee5 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 4 Aug 2026 09:17:46 -0300 Subject: [PATCH] fix(forms): colour a control that is already known to be invalid Input only shows the invalid state after a blur, so a form submitted with Enter shows the error text with a grey border. autoValidate already meant "do not wait for a touch", it was just seeded into state at mount and so could not react to an error arriving later. Reading it live fixes that, and InputGroup's isInvalid prop now reaches the control instead of only setting a class on the wrapper. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/InputGroup.stories.tsx | 15 +++++++++++++++ frontend/web/components/base/forms/Input.tsx | 10 +++++----- frontend/web/components/base/forms/InputGroup.tsx | 15 ++++++++++----- 3 files changed, 30 insertions(+), 10 deletions(-) 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}