Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/documentation/components/InputGroup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<Field
title='Email'
isInvalid
inputProps={{
error: 'A user with this email address already exists.',
name: 'email',
}}
/>
),
}

export const Disabled: Story = {
render: () => <Field title='Email' initialValue='you@example.com' disabled />,
}
Expand Down
10 changes: 5 additions & 5 deletions frontend/web/components/base/forms/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface InputMethods {

export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, '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
Expand Down Expand Up @@ -69,9 +71,7 @@ const Input: React.FC<InputProps> = ({
}) => {
const inputRef = useRef<HTMLInputElement>(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
Expand All @@ -90,7 +90,7 @@ const Input: React.FC<InputProps> = ({

const onBlur = (e: FocusEvent<HTMLInputElement>) => {
setIsFocused(false)
setShouldValidate(true)
setHasBeenTouched(true)
onBlurProp?.(e)
}

Expand All @@ -101,7 +101,7 @@ const Input: React.FC<InputProps> = ({
onKeyDownProp?.(e)
}

const invalid = shouldValidate && !isValid
const invalid = (hasBeenTouched || !!autoValidate) && !isValid
const success = isValid && showSuccess
const sizeClassName = size ? sizeClassNames[size] : ''
const containerClassName = cn(
Expand Down
15 changes: 10 additions & 5 deletions frontend/web/components/base/forms/InputGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ const InputGroup: FC<InputGroupProps> = ({
// 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
Expand Down Expand Up @@ -152,11 +160,8 @@ const InputGroup: FC<InputGroupProps> = ({
inputRef.current = c
}}
{...restInputProps}
isValid={
isValid === null || isValid === undefined
? undefined
: !!isValid
}
autoValidate={!!isInvalid}
isValid={inputIsValid}
disabled={disabled}
defaultValue={defaultValue}
value={value}
Expand Down
Loading