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:
@@ -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,
|
||||
},
|
||||
}
|
||||
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'
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { mergeRefs } from './mergeRefs'
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user