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}