Files
web/packages/design-system/lib/components/Input/Input.tsx
Anton Gunnarsson 66b7af877a Merged in fix/loy-514-fix-validation-tracking-in-signup-form (pull request #3445)
fix(LOY-514): Fix validation error tracking in SignupForm

* Fix issue with form submit handling

* Fix

* Remove browser validation

* Add automatic tracking of validatione rrors


Approved-by: Rasmus Langvad
Approved-by: Matilda Landström
2026-01-19 08:34:24 +00:00

175 lines
5.6 KiB
TypeScript

import { cx } from "class-variance-authority"
import {
type ForwardedRef,
forwardRef,
useId,
useImperativeHandle,
useRef,
} from "react"
import { Input as AriaInput, Label as AriaLabel } from "react-aria-components"
import { InputLabel } from "../InputLabel"
import styles from "./input.module.css"
import { MaterialIcon } from "../Icons/MaterialIcon"
import { Typography } from "../Typography"
import type { InputProps } from "./types"
import { clearInput, useInputHasValue } from "./utils"
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
{
label,
labelPosition = "floating",
leftIcon,
rightIcon,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onRightIconClick,
showClearContentIcon,
placeholder,
id,
required,
"data-validation-state": validationState,
...props
}: InputProps & { "data-validation-state"?: string },
ref: ForwardedRef<HTMLInputElement>
) {
// Create an internal ref that we can access
const internalRef = useRef<HTMLInputElement>(null)
// Generate a unique ID for the input
// This is used to ensure the input is properly associated with the label
// when the label is positioned above the input
const generatedId = useId()
// Forward the ref properly
useImperativeHandle(ref, () => internalRef.current!, [])
// Track whether input has a value (for showing/hiding clear button)
const hasValue = useInputHasValue(props.value, internalRef)
const onClearContent = () => {
clearInput({
inputRef: internalRef,
onChange: props.onChange,
value: props.value,
})
}
// When labelPosition is 'top', restructure to have label outside container
// We need an ID for proper label-input association
if (labelPosition === "top") {
const inputId = id || generatedId
return (
<>
<InputLabel
required={required}
className={cx(
styles.labelAbove,
leftIcon && styles.labelAboveWithLeftIcon
)}
>
{label}
</InputLabel>
<div className={styles.inputContainer}>
{leftIcon && (
<div className={styles.leftIconContainer}>{leftIcon}</div>
)}
<label
htmlFor={inputId}
className={cx(
styles.container,
leftIcon && styles.containerWithLeftIcon,
rightIcon && styles.containerWithRightIcon
)}
data-validation-state={validationState}
>
<Typography variant="Body/Paragraph/mdRegular">
<AriaInput
{...props}
id={inputId}
// Avoid duplicating label text in placeholder when label is positioned above
// Screen readers would announce the label twice (once as label, once as placeholder)
// Only use placeholder if explicitly provided, otherwise use empty string
placeholder={placeholder ?? ""}
className={cx(
styles.input,
styles.inputTopLabel,
props.className
)}
ref={internalRef}
/>
</Typography>
</label>
{showClearContentIcon && hasValue && (
<div className={styles.rightIconContainer}>
<button
type="button"
className={styles.rightIconButton}
onClick={onClearContent}
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
aria-label="Clear content"
>
<MaterialIcon icon="cancel" />
</button>
</div>
)}
{rightIcon && !(showClearContentIcon && hasValue) && (
<div className={styles.rightIconContainer}>{rightIcon}</div>
)}
</div>
</>
)
}
// Floating label (default behavior) - label inside container
return (
<div className={styles.inputContainer}>
{leftIcon && <div className={styles.leftIconContainer}>{leftIcon}</div>}
<AriaLabel
className={cx(
styles.container,
leftIcon && styles.containerWithLeftIcon,
rightIcon && styles.containerWithRightIcon
)}
data-validation-state={validationState}
>
<Typography variant="Body/Paragraph/mdRegular">
<AriaInput
{...props}
id={id}
// For floating labels, only set placeholder if explicitly provided
// The label itself acts as the placeholder, so we don't want to duplicate it
// This ensures the label only floats when focused or has value
placeholder={placeholder}
className={cx(styles.input, props.className)}
ref={internalRef}
/>
</Typography>
<InputLabel required={required}>{label}</InputLabel>
</AriaLabel>
{showClearContentIcon && hasValue && (
<div className={styles.rightIconContainer}>
<button
type="button"
className={styles.rightIconButton}
onClick={onClearContent}
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
aria-label="Clear content"
>
<MaterialIcon icon="cancel" />
</button>
</div>
)}
{rightIcon && !(showClearContentIcon && hasValue) && (
<div className={styles.rightIconContainer}>{rightIcon}</div>
)}
</div>
)
})
export const Input = InputComponent as React.ForwardRefExoticComponent<
InputProps & React.RefAttributes<HTMLInputElement>
>