Merged in feat/3685-new-textarea-component (pull request #3392)

feat(SW-3685): Add new TextArea and FormTextArea components

* Add new TextArea and FormTextArea components

* Update example form with description

* Merge branch 'master' into feat/3685-new-textarea-component

* Formatting new files with new prettier config

* Added custom controls for the text area story


Approved-by: Linus Flood
This commit is contained in:
Rasmus Langvad
2026-01-07 17:04:30 +00:00
parent d0546926a9
commit 4980cc830d
17 changed files with 752 additions and 304 deletions

View File

@@ -1,45 +1,52 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useEffect } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { fn } from 'storybook/test'
import { z } from 'zod'
import { zodResolver } from "@hookform/resolvers/zod"
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { fn } from "storybook/test"
import { z } from "zod"
import { Button } from '../../Button'
import { MaterialIcon } from '../../Icons/MaterialIcon'
import { Typography } from '../../Typography'
import { FormInput } from '../FormInput'
import { FormTextArea } from "../FormTextArea"
import { Button } from "../../Button"
import { MaterialIcon } from "../../Icons/MaterialIcon"
import { Typography } from "../../Typography"
import { FormInput } from "../FormInput"
const createExampleFormSchema = (prefix?: string) => {
const getKey = (key: string) => (prefix ? `${prefix}_${key}` : key)
return z.object({
[getKey('firstName')]: z
[getKey("firstName")]: z
.string()
.min(2, 'First name must be at least 2 characters'),
[getKey('lastName')]: z
.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
.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'),
.min(10, "Message must be at least 10 characters"),
})
}
interface ExampleFormProps {
onSubmit?: (data: Record<string, unknown>) => void
labelPosition?: 'floating' | 'top'
labelPosition?: "floating" | "top"
defaultValues?: Record<string, unknown>
fieldPrefix?: string
textAreaDescription?: string
textAreaError?: string
textAreaLabel?: string
}
function ExampleFormComponent({
onSubmit,
labelPosition = 'floating',
labelPosition = "floating",
defaultValues,
fieldPrefix = '',
fieldPrefix = "",
textAreaDescription,
textAreaError,
textAreaLabel = "Message",
}: ExampleFormProps) {
const getFieldName = (name: string) =>
fieldPrefix ? `${fieldPrefix}_${name}` : name
@@ -49,16 +56,29 @@ function ExampleFormComponent({
const methods = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
[getFieldName('firstName')]: '',
[getFieldName('lastName')]: '',
[getFieldName('email')]: '',
[getFieldName('phone')]: '',
[getFieldName('company')]: '',
[getFieldName('message')]: '',
[getFieldName("firstName")]: "",
[getFieldName("lastName")]: "",
[getFieldName("email")]: "",
[getFieldName("phone")]: "",
[getFieldName("company")]: "",
[getFieldName("message")]: "",
...(defaultValues as Partial<FormData>),
},
})
const messageFieldName = getFieldName("message")
useEffect(() => {
if (textAreaError) {
methods.setError(messageFieldName, {
type: "manual",
message: textAreaError,
})
} else {
methods.clearErrors(messageFieldName)
}
}, [textAreaError, methods, messageFieldName])
const handleSubmit = methods.handleSubmit((data) => {
onSubmit?.(data)
})
@@ -68,26 +88,26 @@ function ExampleFormComponent({
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
maxWidth: '500px',
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%' }}>
<div style={{ display: "flex", gap: "1rem", width: "100%" }}>
<FormInput
name={getFieldName('firstName')}
name={getFieldName("firstName")}
label="First name"
autoComplete="given-name"
labelPosition={labelPosition}
registerOptions={{ required: true }}
/>
<FormInput
name={getFieldName('lastName')}
name={getFieldName("lastName")}
label="Last name"
autoComplete="family-name"
labelPosition={labelPosition}
@@ -96,7 +116,7 @@ function ExampleFormComponent({
</div>
<FormInput
name={getFieldName('email')}
name={getFieldName("email")}
label="Email"
type="email"
autoComplete="email"
@@ -105,7 +125,7 @@ function ExampleFormComponent({
/>
<FormInput
name={getFieldName('phone')}
name={getFieldName("phone")}
label="Phone (optional)"
type="tel"
autoComplete="tel"
@@ -114,17 +134,18 @@ function ExampleFormComponent({
/>
<FormInput
name={getFieldName('company')}
name={getFieldName("company")}
label="Company (optional)"
autoComplete="organization"
labelPosition={labelPosition}
/>
<FormInput
name={getFieldName('message')}
label="Message"
labelPosition={labelPosition}
<FormTextArea
name={getFieldName("message")}
label={textAreaLabel || undefined}
registerOptions={{ required: true }}
descriptionIcon="info"
description={textAreaDescription}
/>
<Button type="submit" variant="Primary" size="lg">
@@ -136,20 +157,32 @@ function ExampleFormComponent({
}
const meta: Meta<typeof ExampleFormComponent> = {
title: 'Compositions/Form/ExampleForm',
title: "Compositions/Form/ExampleForm",
parameters: {
layout: 'padded',
layout: "padded",
},
argTypes: {
labelPosition: {
control: 'select',
options: ['floating', 'top'],
description: 'Position of labels for all input fields in the form',
control: "select",
options: ["floating", "top"],
description: "Position of labels for all input fields in the form",
table: {
type: { summary: "'floating' | 'top'" },
defaultValue: { summary: "'floating'" },
},
},
textAreaLabel: {
control: "text",
description: "Label for the TextArea field (empty to hide)",
},
textAreaDescription: {
control: "text",
description: "Description text shown below the TextArea",
},
textAreaError: {
control: "text",
description: "Custom error message to display for the TextArea",
},
},
}
@@ -160,14 +193,17 @@ type Story = StoryObj<typeof ExampleFormComponent>
export const Default: Story = {
render: (args) => (
<ExampleFormComponent
key={`label-${args.labelPosition || 'floating'}`}
key={`label-${args.labelPosition || "floating"}`}
{...args}
fieldPrefix="example"
/>
),
args: {
onSubmit: fn(),
labelPosition: 'floating',
labelPosition: "floating",
textAreaLabel: "Message",
textAreaDescription: "This is a custom description",
textAreaError: "",
},
}
@@ -179,46 +215,46 @@ const signupSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.min(3, "Username must be at least 3 characters")
.regex(
/^[a-z0-9_]+$/,
'Username can only contain lowercase letters, numbers, and underscores'
"Username can only contain lowercase letters, numbers, and underscores"
),
email: z.string().email('Please enter a valid email address'),
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'),
.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'],
message: "Passwords do not match",
path: ["confirmPassword"],
})
type SignupFormData = z.infer<typeof signupSchema>
interface SignupFormProps {
onSubmit?: (data: SignupFormData) => void
labelPosition?: 'floating' | 'top'
labelPosition?: "floating" | "top"
showErrors?: boolean
}
function SignupFormComponent({
onSubmit,
labelPosition = 'floating',
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' : '',
username: showErrors ? "A" : "",
email: showErrors ? "invalid-email" : "",
password: showErrors ? "weak" : "",
confirmPassword: showErrors ? "nomatch" : "",
},
mode: 'onChange',
mode: "onChange",
})
// Trigger validation on mount if showErrors is true
@@ -239,10 +275,10 @@ function SignupFormComponent({
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
maxWidth: '400px',
display: "flex",
flexDirection: "column",
gap: "1.5rem",
maxWidth: "400px",
}}
>
<Typography variant="Title/md">
@@ -254,7 +290,7 @@ function SignupFormComponent({
label="Username"
autoComplete="username"
labelPosition={labelPosition}
description={!errors.username ? 'Username is available' : undefined}
description={!errors.username ? "Username is available" : undefined}
registerOptions={{ required: true }}
/>
@@ -265,7 +301,7 @@ function SignupFormComponent({
autoComplete="email"
labelPosition={labelPosition}
description={
!errors.email ? 'We will send a verification email' : undefined
!errors.email ? "We will send a verification email" : undefined
}
registerOptions={{ required: true }}
/>
@@ -277,7 +313,7 @@ function SignupFormComponent({
autoComplete="new-password"
labelPosition={labelPosition}
description={
!errors.password ? 'Password meets all requirements' : undefined
!errors.password ? "Password meets all requirements" : undefined
}
registerOptions={{ required: true }}
/>
@@ -288,7 +324,7 @@ function SignupFormComponent({
type="password"
autoComplete="new-password"
labelPosition={labelPosition}
description={!errors.confirmPassword ? 'Passwords match' : undefined}
description={!errors.confirmPassword ? "Passwords match" : undefined}
registerOptions={{ required: true }}
/>
@@ -301,20 +337,20 @@ function SignupFormComponent({
}
const signupMeta: Meta<typeof SignupFormComponent> = {
title: 'Compositions/Form/SignupForm',
title: "Compositions/Form/SignupForm",
component: SignupFormComponent,
parameters: {
layout: 'padded',
layout: "padded",
},
argTypes: {
labelPosition: {
control: 'select',
options: ['floating', 'top'],
description: 'Position of the labels',
control: "select",
options: ["floating", "top"],
description: "Position of the labels",
},
showErrors: {
control: 'boolean',
description: 'Show validation errors on mount',
control: "boolean",
description: "Show validation errors on mount",
},
},
}
@@ -325,7 +361,7 @@ export const WithDescriptions: SignupStory = {
render: (args) => <SignupFormComponent {...args} />,
args: {
onSubmit: fn(),
labelPosition: 'floating',
labelPosition: "floating",
showErrors: false,
},
parameters: {
@@ -337,7 +373,7 @@ export const WithErrors: SignupStory = {
render: (args) => <SignupFormComponent {...args} />,
args: {
onSubmit: fn(),
labelPosition: 'floating',
labelPosition: "floating",
showErrors: true,
},
parameters: {
@@ -353,7 +389,7 @@ 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'),
required: z.string().min(1, "This field is required"),
disabled: z.string().optional(),
disabledFilled: z.string().optional(),
warningState: z.string().optional(),
@@ -370,8 +406,8 @@ const showcaseSchema = z.object({
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'),
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(),
@@ -381,7 +417,7 @@ const showcaseSchema = z.object({
passwordType: z.string().optional(),
urlType: z.string().optional(),
combined1: z.string().optional(),
combined2: z.string().email('Invalid email'),
combined2: z.string().email("Invalid email"),
combined3: z.string().optional(),
})
@@ -391,35 +427,35 @@ 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',
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',
mode: "onChange",
})
// Trigger validation for error examples on mount
useEffect(() => {
methods.trigger(['error', 'errorFilled', 'combined2'])
methods.trigger(["error", "errorFilled", "combined2"])
}, [methods])
return (
<FormProvider {...methods}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '3rem',
maxWidth: '800px',
padding: '2rem',
display: "flex",
flexDirection: "column",
gap: "3rem",
maxWidth: "800px",
padding: "2rem",
}}
>
<Typography variant="Title/lg">
@@ -433,10 +469,10 @@ function InputShowcase() {
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput name="default" label="Default" />
@@ -485,10 +521,10 @@ function InputShowcase() {
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
@@ -516,10 +552,10 @@ function InputShowcase() {
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
@@ -558,10 +594,10 @@ function InputShowcase() {
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
@@ -606,10 +642,10 @@ function InputShowcase() {
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
@@ -642,10 +678,10 @@ function InputShowcase() {
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1.5rem',
marginTop: '1rem',
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput name="text" label="Text" type="text" />
@@ -659,10 +695,10 @@ function InputShowcase() {
}
const showcaseMeta: Meta<typeof InputShowcase> = {
title: 'Compositions/Form/InputShowcase',
title: "Compositions/Form/InputShowcase",
component: InputShowcase,
parameters: {
layout: 'fullscreen',
layout: "fullscreen",
},
}