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:
Rasmus Langvad
2025-12-08 08:51:03 +00:00
parent de4b3c1c3c
commit edca33c49f
33 changed files with 2159 additions and 71 deletions

View File

@@ -0,0 +1,691 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { fn } from 'storybook/test'
import { useEffect } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { FormInput } from '../FormInput'
import { Button } from '../../Button'
import { Typography } from '../../Typography'
import { MaterialIcon } from '../../Icons/MaterialIcon'
const createExampleFormSchema = (prefix?: string) => {
const getKey = (key: string) => (prefix ? `${prefix}_${key}` : key)
return z.object({
[getKey('firstName')]: z
.string()
.min(2, 'First name must be at least 2 characters'),
[getKey('lastName')]: z
.string()
.min(2, 'Last name must be at least 2 characters'),
[getKey('email')]: z.string().email('Please enter a valid email address'),
[getKey('phone')]: z.string().optional(),
[getKey('company')]: z.string().optional(),
[getKey('message')]: z
.string()
.min(10, 'Message must be at least 10 characters'),
})
}
interface ExampleFormProps {
onSubmit?: (data: Record<string, unknown>) => void
labelPosition?: 'floating' | 'top'
defaultValues?: Record<string, unknown>
fieldPrefix?: string
}
function ExampleFormComponent({
onSubmit,
labelPosition = 'floating',
defaultValues,
fieldPrefix = '',
}: ExampleFormProps) {
const getFieldName = (name: string) =>
fieldPrefix ? `${fieldPrefix}_${name}` : name
const schema = createExampleFormSchema(fieldPrefix || undefined)
type FormData = z.infer<typeof schema>
const methods = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
[getFieldName('firstName')]: '',
[getFieldName('lastName')]: '',
[getFieldName('email')]: '',
[getFieldName('phone')]: '',
[getFieldName('company')]: '',
[getFieldName('message')]: '',
...(defaultValues as Partial<FormData>),
},
})
const handleSubmit = methods.handleSubmit((data) => {
onSubmit?.(data)
})
return (
<FormProvider {...methods}>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
maxWidth: '500px',
}}
>
<Typography variant="Title/md">
<h2>Example Form</h2>
</Typography>
<div style={{ display: 'flex', gap: '1rem', width: '100%' }}>
<FormInput
name={getFieldName('firstName')}
label="First name"
autoComplete="given-name"
labelPosition={labelPosition}
registerOptions={{ required: true }}
/>
<FormInput
name={getFieldName('lastName')}
label="Last name"
autoComplete="family-name"
labelPosition={labelPosition}
registerOptions={{ required: true }}
/>
</div>
<FormInput
name={getFieldName('email')}
label="Email"
type="email"
autoComplete="email"
labelPosition={labelPosition}
registerOptions={{ required: true }}
/>
<FormInput
name={getFieldName('phone')}
label="Phone (optional)"
type="tel"
autoComplete="tel"
inputMode="tel"
labelPosition={labelPosition}
/>
<FormInput
name={getFieldName('company')}
label="Company (optional)"
autoComplete="organization"
labelPosition={labelPosition}
/>
<FormInput
name={getFieldName('message')}
label="Message"
labelPosition={labelPosition}
registerOptions={{ required: true }}
/>
<Button type="submit" variant="Primary" size="Large">
Send message
</Button>
</form>
</FormProvider>
)
}
const meta: Meta<typeof ExampleFormComponent> = {
title: 'Compositions/Form/ExampleForm',
component: ExampleFormComponent,
parameters: {
layout: 'padded',
},
}
export default meta
type Story = StoryObj<typeof ExampleFormComponent>
export const LabelFloating: Story = {
render: (args) => (
<ExampleFormComponent
key="label-on-top"
{...args}
labelPosition="floating"
/>
),
args: {
onSubmit: fn(),
},
}
export const LabelOnTop: Story = {
render: (args) => (
<ExampleFormComponent
key="label-on-top"
{...args}
labelPosition="top"
fieldPrefix="top"
/>
),
args: {
onSubmit: fn(),
},
}
// ============================================================================
// Form with Errors and Descriptions
// ============================================================================
const signupSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.regex(
/^[a-z0-9_]+$/,
'Username can only contain lowercase letters, numbers, and underscores'
),
email: z.string().email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
type SignupFormData = z.infer<typeof signupSchema>
interface SignupFormProps {
onSubmit?: (data: SignupFormData) => void
labelPosition?: 'floating' | 'top'
showErrors?: boolean
}
function SignupFormComponent({
onSubmit,
labelPosition = 'floating',
showErrors = false,
}: SignupFormProps) {
const methods = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
defaultValues: {
username: showErrors ? 'A' : '',
email: showErrors ? 'invalid-email' : '',
password: showErrors ? 'weak' : '',
confirmPassword: showErrors ? 'nomatch' : '',
},
mode: 'onChange',
})
// Trigger validation on mount if showErrors is true
useEffect(() => {
if (showErrors) {
methods.trigger()
}
}, [showErrors, methods])
const handleSubmit = methods.handleSubmit((data) => {
onSubmit?.(data)
})
const { errors } = methods.formState
return (
<FormProvider {...methods}>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
maxWidth: '400px',
}}
>
<Typography variant="Title/md">
<h2>Create Account</h2>
</Typography>
<FormInput
name="username"
label="Username"
autoComplete="username"
labelPosition={labelPosition}
description={!errors.username ? 'Username is available' : undefined}
registerOptions={{ required: true }}
/>
<FormInput
name="email"
label="Email"
type="email"
autoComplete="email"
labelPosition={labelPosition}
description={
!errors.email ? 'We will send a verification email' : undefined
}
registerOptions={{ required: true }}
/>
<FormInput
name="password"
label="Password"
type="password"
autoComplete="new-password"
labelPosition={labelPosition}
description={
!errors.password ? 'Password meets all requirements' : undefined
}
registerOptions={{ required: true }}
/>
<FormInput
name="confirmPassword"
label="Confirm password"
type="password"
autoComplete="new-password"
labelPosition={labelPosition}
description={!errors.confirmPassword ? 'Passwords match' : undefined}
registerOptions={{ required: true }}
/>
<Button type="submit" variant="Primary" size="Large">
Create account
</Button>
</form>
</FormProvider>
)
}
const signupMeta: Meta<typeof SignupFormComponent> = {
title: 'Compositions/Form/SignupForm',
component: SignupFormComponent,
parameters: {
layout: 'padded',
},
argTypes: {
labelPosition: {
control: 'select',
options: ['floating', 'top'],
description: 'Position of the labels',
},
showErrors: {
control: 'boolean',
description: 'Show validation errors on mount',
},
},
}
type SignupStory = StoryObj<typeof SignupFormComponent>
export const WithDescriptions: SignupStory = {
render: (args) => <SignupFormComponent {...args} />,
args: {
onSubmit: fn(),
labelPosition: 'floating',
showErrors: false,
},
parameters: {
...signupMeta.parameters,
},
}
export const WithErrors: SignupStory = {
render: (args) => <SignupFormComponent {...args} />,
args: {
onSubmit: fn(),
labelPosition: 'floating',
showErrors: true,
},
parameters: {
...signupMeta.parameters,
},
}
export const WithErrorsLabelOnTop: SignupStory = {
render: (args) => <SignupFormComponent {...args} />,
args: {
onSubmit: fn(),
labelPosition: 'top',
showErrors: true,
},
parameters: {
...signupMeta.parameters,
},
}
// ============================================================================
// Input Variations Showcase
// ============================================================================
const showcaseSchema = z.object({
default: z.string().optional(),
placeholder: z.string().optional(),
filled: z.string().optional(),
required: z.string().min(1, 'This field is required'),
disabled: z.string().optional(),
disabledFilled: z.string().optional(),
warningState: z.string().optional(),
warningFilled: z.string().optional(),
emailIcon: z.string().optional(),
searchIcon: z.string().optional(),
locked: z.string().optional(),
bothIcons: z.string().optional(),
emailIconTop: z.string().optional(),
searchIconTop: z.string().optional(),
lockedTop: z.string().optional(),
bothIconsTop: z.string().optional(),
emailClear: z.string().optional(),
searchClear: z.string().optional(),
clearLeftRight: z.string().optional(),
emptyClear: z.string().optional(),
error: z.string().min(10, 'Must be at least 10 characters'),
errorFilled: z.string().email('Invalid email'),
warning: z.string().optional(),
warningFilledValidation: z.string().optional(),
text: z.string().optional(),
emailType: z.string().optional(),
telType: z.string().optional(),
number: z.string().optional(),
passwordType: z.string().optional(),
urlType: z.string().optional(),
combined1: z.string().optional(),
combined2: z.string().email('Invalid email'),
combined3: z.string().optional(),
})
type ShowcaseFormData = z.infer<typeof showcaseSchema>
function InputShowcase() {
const methods = useForm<ShowcaseFormData>({
resolver: zodResolver(showcaseSchema),
defaultValues: {
filled: 'Sample text',
disabledFilled: 'Cannot edit',
warningFilled: 'Needs attention',
emailClear: 'user@example.com',
searchClear: '',
clearLeftRight: '+46 70 123 45 67',
error: 'Short',
errorFilled: 'Invalid input',
warningFilledValidation: 'Needs attention',
combined1: 'user@example.com',
combined2: 'Invalid email',
},
mode: 'onChange',
})
// Trigger validation for error examples on mount
useEffect(() => {
methods.trigger(['error', 'errorFilled', 'combined2'])
}, [methods])
return (
<FormProvider {...methods}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '3rem',
maxWidth: '800px',
padding: '2rem',
}}
>
<Typography variant="Title/lg">
<h1>FormInput Component Showcase</h1>
</Typography>
{/* Basic States */}
<section>
<Typography variant="Title/md">
<h2>Basic States</h2>
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
}}
>
<FormInput name="default" label="Default" />
<FormInput
name="placeholder"
label="Placeholder"
placeholder="Enter text here..."
/>
<FormInput
name="default2"
label="Default"
description="This is a default input with a description"
/>
<FormInput
name="default3"
label="Default"
placeholder="Enter text here..."
description="This is a default input with a description"
/>
<FormInput name="filled" label="Filled" />
<FormInput
name="required"
label="Required"
registerOptions={{ required: true }}
hideError
/>
<FormInput name="disabled" label="Disabled" disabled />
<FormInput name="disabledFilled" label="Disabled Filled" disabled />
<FormInput
name="warningState"
label="Warning State"
validationState="warning"
/>
<FormInput
name="warningFilled"
label="Warning with Value"
validationState="warning"
/>
</div>
</section>
{/* With Icons */}
<section>
<Typography variant="Title/md">
<h2>With Icons</h2>
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
}}
>
<FormInput
name="emailIcon"
label="Email"
type="email"
leftIcon={<MaterialIcon icon="email" />}
/>
<FormInput
name="searchIcon"
label="Search"
leftIcon={<MaterialIcon icon="search" />}
/>
<FormInput
name="locked"
label="Locked icon"
rightIcon={<MaterialIcon icon="lock" />}
/>
<FormInput
name="bothIcons"
label="With Both Icons"
leftIcon={<MaterialIcon icon="person" />}
rightIcon={<MaterialIcon icon="check_circle" />}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
}}
>
<FormInput
name="emailIconTop"
label="Email"
type="email"
leftIcon={<MaterialIcon icon="email" />}
labelPosition="top"
/>
<FormInput
name="searchIconTop"
label="Search"
leftIcon={<MaterialIcon icon="search" />}
labelPosition="top"
/>
<FormInput
name="lockedTop"
label="Locked icon"
rightIcon={<MaterialIcon icon="lock" />}
labelPosition="top"
/>
<FormInput
name="bothIconsTop"
label="With Both Icons"
leftIcon={<MaterialIcon icon="person" />}
rightIcon={<MaterialIcon icon="check_circle" />}
labelPosition="top"
/>
</div>
</section>
{/* Clear Button */}
<section>
<Typography variant="Title/md">
<h2>Clear Button</h2>
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
}}
>
<FormInput
name="emailClear"
label="Email with Clear"
type="email"
showClearContentIcon
/>
<FormInput
name="searchClear"
label="Search with Clear"
leftIcon={<MaterialIcon icon="search" />}
showClearContentIcon
/>
<FormInput
name="clearLeftRight"
label="Clear with Left Icon"
leftIcon={<MaterialIcon icon="phone" />}
showClearContentIcon
/>
<FormInput
name="clearRightIcon"
label="Clear with Right Icon"
rightIcon={<MaterialIcon icon="lock" />}
showClearContentIcon
/>
<FormInput
name="clearBothIcons"
label="Clear with Both Icon"
leftIcon={<MaterialIcon icon="email" />}
rightIcon={<MaterialIcon icon="lock" />}
showClearContentIcon
/>
<FormInput name="emptyClear" label="Empty" showClearContentIcon />
</div>
</section>
{/* Validation States */}
<section>
<Typography variant="Title/md">
<h2>Validation States</h2>
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
}}
>
<FormInput
name="error"
label="Error State"
registerOptions={{ minLength: 10 }}
/>
<FormInput
name="errorFilled"
label="Error with Value"
registerOptions={{ required: true }}
/>
<FormInput
name="warning"
label="Warning State"
validationState="warning"
/>
<FormInput
name="warningFilledValidation"
label="Warning with Value"
validationState="warning"
/>
</div>
</section>
{/* Input Types */}
<section>
<Typography variant="Title/md">
<h2>Input Types</h2>
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
}}
>
<FormInput name="text" label="Text" type="text" />
<FormInput name="emailType" label="Email" type="email" />
<FormInput name="number" label="Number" type="number" />
</div>
</section>
</div>
</FormProvider>
)
}
const showcaseMeta: Meta<typeof InputShowcase> = {
title: 'Compositions/Form/InputShowcase',
component: InputShowcase,
parameters: {
layout: 'fullscreen',
},
}
type ShowcaseStory = StoryObj<typeof InputShowcase>
export const AllVariations: ShowcaseStory = {
render: () => <InputShowcase />,
parameters: {
...showcaseMeta.parameters,
},
}

View 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'

View File

@@ -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);
}

View File

@@ -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'
}

View File

@@ -16,8 +16,8 @@ import {
import { ErrorMessage } from '../ErrorMessage'
import { MaterialIcon } from '../../Icons/MaterialIcon'
import { Input } from '../../Input'
import { Label } from '../../Label'
import { Input } from '../../InputNew'
import { InputLabel } from '../../InputLabel'
import styles from './phone.module.css'
@@ -100,13 +100,13 @@ export default function Phone({
type="button"
data-testid="country-selector"
>
<Label
<InputLabel
required={!!registerOptions.required}
size="small"
disabled={disabled}
>
{countryLabel}
</Label>
</InputLabel>
<span className={styles.selectContainer}>
{props.children}
<Typography variant="Body/Paragraph/mdRegular">

View File

@@ -9,7 +9,7 @@ import {
import { Controller, useFormContext } from 'react-hook-form'
import { MaterialIcon } from '../../Icons/MaterialIcon'
import { Label } from '../../Label'
import { InputLabel } from '../../InputLabel'
import { Typography } from '../../Typography'
import styles from './textarea.module.css'
@@ -57,7 +57,9 @@ export default function TextArea({
className={styles.textarea}
/>
</Typography>
<Label required={!!registerOptions.required}>{label}</Label>
<InputLabel required={!!registerOptions.required}>
{label}
</InputLabel>
</AriaLabel>
{helpText && !fieldState.error ? (
<Typography variant="Body/Supporting text (caption)/smRegular">

View File

@@ -0,0 +1 @@
export { mergeRefs } from './mergeRefs'

View File

@@ -0,0 +1,24 @@
import type { Ref, RefCallback } from 'react'
/**
* Merges multiple refs into a single ref callback.
* Useful when you need to forward a ref while also using react-hook-form's field.ref.
*
* @example
* ```tsx
* <Input ref={mergeRefs(field.ref, forwardedRef)} />
* ```
*/
export function mergeRefs<T>(
...refs: Array<Ref<T> | undefined>
): RefCallback<T> {
return (node: T | null) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
})
}
}