Merged in feat/SW-3655-input-component (pull request #3296)
feat: (SW-3655) new Input and FormInput components * First version new Input and FormInput components * Handle aria-describedby with react-aria instead of manually add it * Update breaking unit and stories tests * Merge branch 'master' into feat/SW-3655-input-component * Update example form * Merge branch 'master' into feat/SW-3655-input-component * New lock file Approved-by: Linus Flood
This commit is contained in:
124
packages/design-system/lib/components/Form/FormInput/index.tsx
Normal file
124
packages/design-system/lib/components/Form/FormInput/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, type HTMLAttributes, type WheelEvent } from 'react'
|
||||
import { Text, TextField } from 'react-aria-components'
|
||||
import { Controller, useFormContext } from 'react-hook-form'
|
||||
import { useIntl, type IntlShape } from 'react-intl'
|
||||
import { cx } from 'class-variance-authority'
|
||||
|
||||
import { Error } from '../ErrorMessage/Error'
|
||||
import { mergeRefs } from '../utils/mergeRefs'
|
||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||
import { Input } from '../../InputNew'
|
||||
|
||||
import styles from './input.module.css'
|
||||
|
||||
import type { FormInputProps } from './input'
|
||||
|
||||
const defaultErrorFormatter = (
|
||||
_intl: IntlShape,
|
||||
errorMessage?: string
|
||||
): string => errorMessage ?? ''
|
||||
|
||||
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||
function FormInput(
|
||||
{
|
||||
autoComplete,
|
||||
className = '',
|
||||
description = '',
|
||||
disabled = false,
|
||||
errorFormatter,
|
||||
hideError,
|
||||
inputMode,
|
||||
label,
|
||||
labelPosition = 'floating',
|
||||
maxLength,
|
||||
name,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
registerOptions = {},
|
||||
type = 'text',
|
||||
validationState,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const intl = useIntl()
|
||||
const { control } = useFormContext()
|
||||
|
||||
const formatErrorMessage = errorFormatter ?? defaultErrorFormatter
|
||||
|
||||
// Number input: prevent scroll from changing value
|
||||
const numberAttributes: HTMLAttributes<HTMLInputElement> =
|
||||
type === 'number'
|
||||
? {
|
||||
onWheel: (evt: WheelEvent<HTMLInputElement>) => {
|
||||
evt.currentTarget.blur()
|
||||
},
|
||||
}
|
||||
: {}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
rules={registerOptions}
|
||||
render={({ field, fieldState }) => {
|
||||
const isDisabled = disabled || field.disabled
|
||||
const hasError = fieldState.invalid && !hideError
|
||||
const showDescription = description && !fieldState.error
|
||||
|
||||
return (
|
||||
// Note: No aria-label needed on TextField since the Input component
|
||||
// always renders a visible label that provides the accessible name
|
||||
<TextField
|
||||
className={cx(styles.wrapper, className)}
|
||||
isDisabled={isDisabled}
|
||||
isReadOnly={readOnly}
|
||||
isInvalid={fieldState.invalid}
|
||||
isRequired={!!registerOptions.required}
|
||||
>
|
||||
<Input
|
||||
{...props}
|
||||
{...numberAttributes}
|
||||
ref={mergeRefs(field.ref, ref)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? ''}
|
||||
autoComplete={autoComplete}
|
||||
id={field.name}
|
||||
label={label}
|
||||
labelPosition={labelPosition}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={isDisabled}
|
||||
required={!!registerOptions.required}
|
||||
type={type}
|
||||
inputMode={inputMode}
|
||||
// Only 'warning' is passed through; error state is handled via isInvalid
|
||||
data-validation-state={validationState}
|
||||
/>
|
||||
{showDescription ? (
|
||||
<Text className={styles.description} slot="description">
|
||||
<MaterialIcon icon="info" size={20} />
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
{hasError && fieldState.error ? (
|
||||
<Text slot="errorMessage" aria-live="polite">
|
||||
<Error>
|
||||
{formatErrorMessage(intl, fieldState.error.message)}
|
||||
</Error>
|
||||
</Text>
|
||||
) : null}
|
||||
</TextField>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
FormInput.displayName = 'FormInput'
|
||||
@@ -0,0 +1,15 @@
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x05);
|
||||
margin-top: var(--Space-x1);
|
||||
font-size: var(--Body-Supporting-text-Size);
|
||||
font-family: var(--Body-Supporting-text-Font-family, 'Fira Sans');
|
||||
font-style: normal;
|
||||
font-weight: var(--Body-Supporting-text-Font-weight);
|
||||
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RegisterOptions } from 'react-hook-form'
|
||||
import type { IntlShape } from 'react-intl'
|
||||
|
||||
import type { InputProps } from '../../InputNew/types'
|
||||
|
||||
export interface FormInputProps extends InputProps {
|
||||
/** Helper text displayed below the input (hidden when there's an error) */
|
||||
description?: string
|
||||
/** Field name for react-hook-form registration */
|
||||
name: string
|
||||
/** react-hook-form validation rules */
|
||||
registerOptions?: RegisterOptions
|
||||
/** Hide the error message (useful when showing errors elsewhere) */
|
||||
hideError?: boolean
|
||||
/** Custom formatter for error messages with i18n support */
|
||||
errorFormatter?: (intl: IntlShape, errorMessage?: string) => string
|
||||
/**
|
||||
* Visual validation state for the input.
|
||||
* - 'warning': Shows warning styling (yellow background, focus ring)
|
||||
* - Note: Error state is automatically derived from form validation
|
||||
*/
|
||||
validationState?: 'warning'
|
||||
}
|
||||
Reference in New Issue
Block a user