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:
@@ -3,7 +3,7 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
import TextArea from "@scandic-hotels/design-system/Form/TextArea"
|
import { TextArea } from "@scandic-hotels/design-system/TextArea"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import TextArea from "@scandic-hotels/design-system/Form/TextArea"
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { FormTextArea } from "@scandic-hotels/design-system/Form/FormTextArea"
|
||||||
import styles from "./specialRequests.module.css"
|
import styles from "./specialRequests.module.css"
|
||||||
|
|
||||||
import type { RegisterOptions } from "react-hook-form"
|
import type { RegisterOptions } from "react-hook-form"
|
||||||
@@ -63,7 +63,7 @@ export function SpecialRequests({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/> */}
|
/> */}
|
||||||
<TextArea
|
<FormTextArea
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "enterDetails.specialRequests.commentLabel",
|
id: "enterDetails.specialRequests.commentLabel",
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
|
|||||||
@@ -1,45 +1,52 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from "react"
|
||||||
import { FormProvider, useForm } from 'react-hook-form'
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { fn } from 'storybook/test'
|
import { fn } from "storybook/test"
|
||||||
import { z } from 'zod'
|
import { z } from "zod"
|
||||||
|
|
||||||
import { Button } from '../../Button'
|
import { FormTextArea } from "../FormTextArea"
|
||||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
import { Button } from "../../Button"
|
||||||
import { Typography } from '../../Typography'
|
import { MaterialIcon } from "../../Icons/MaterialIcon"
|
||||||
import { FormInput } from '../FormInput'
|
import { Typography } from "../../Typography"
|
||||||
|
import { FormInput } from "../FormInput"
|
||||||
|
|
||||||
const createExampleFormSchema = (prefix?: string) => {
|
const createExampleFormSchema = (prefix?: string) => {
|
||||||
const getKey = (key: string) => (prefix ? `${prefix}_${key}` : key)
|
const getKey = (key: string) => (prefix ? `${prefix}_${key}` : key)
|
||||||
return z.object({
|
return z.object({
|
||||||
[getKey('firstName')]: z
|
[getKey("firstName")]: z
|
||||||
.string()
|
.string()
|
||||||
.min(2, 'First name must be at least 2 characters'),
|
.min(2, "First name must be at least 2 characters"),
|
||||||
[getKey('lastName')]: z
|
[getKey("lastName")]: z
|
||||||
.string()
|
.string()
|
||||||
.min(2, 'Last name must be at least 2 characters'),
|
.min(2, "Last name must be at least 2 characters"),
|
||||||
[getKey('email')]: z.string().email('Please enter a valid email address'),
|
[getKey("email")]: z.string().email("Please enter a valid email address"),
|
||||||
[getKey('phone')]: z.string().optional(),
|
[getKey("phone")]: z.string().optional(),
|
||||||
[getKey('company')]: z.string().optional(),
|
[getKey("company")]: z.string().optional(),
|
||||||
[getKey('message')]: z
|
[getKey("message")]: z
|
||||||
.string()
|
.string()
|
||||||
.min(10, 'Message must be at least 10 characters'),
|
.min(10, "Message must be at least 10 characters"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExampleFormProps {
|
interface ExampleFormProps {
|
||||||
onSubmit?: (data: Record<string, unknown>) => void
|
onSubmit?: (data: Record<string, unknown>) => void
|
||||||
labelPosition?: 'floating' | 'top'
|
labelPosition?: "floating" | "top"
|
||||||
defaultValues?: Record<string, unknown>
|
defaultValues?: Record<string, unknown>
|
||||||
fieldPrefix?: string
|
fieldPrefix?: string
|
||||||
|
textAreaDescription?: string
|
||||||
|
textAreaError?: string
|
||||||
|
textAreaLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExampleFormComponent({
|
function ExampleFormComponent({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
labelPosition = 'floating',
|
labelPosition = "floating",
|
||||||
defaultValues,
|
defaultValues,
|
||||||
fieldPrefix = '',
|
fieldPrefix = "",
|
||||||
|
textAreaDescription,
|
||||||
|
textAreaError,
|
||||||
|
textAreaLabel = "Message",
|
||||||
}: ExampleFormProps) {
|
}: ExampleFormProps) {
|
||||||
const getFieldName = (name: string) =>
|
const getFieldName = (name: string) =>
|
||||||
fieldPrefix ? `${fieldPrefix}_${name}` : name
|
fieldPrefix ? `${fieldPrefix}_${name}` : name
|
||||||
@@ -49,16 +56,29 @@ function ExampleFormComponent({
|
|||||||
const methods = useForm<FormData>({
|
const methods = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
[getFieldName('firstName')]: '',
|
[getFieldName("firstName")]: "",
|
||||||
[getFieldName('lastName')]: '',
|
[getFieldName("lastName")]: "",
|
||||||
[getFieldName('email')]: '',
|
[getFieldName("email")]: "",
|
||||||
[getFieldName('phone')]: '',
|
[getFieldName("phone")]: "",
|
||||||
[getFieldName('company')]: '',
|
[getFieldName("company")]: "",
|
||||||
[getFieldName('message')]: '',
|
[getFieldName("message")]: "",
|
||||||
...(defaultValues as Partial<FormData>),
|
...(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) => {
|
const handleSubmit = methods.handleSubmit((data) => {
|
||||||
onSubmit?.(data)
|
onSubmit?.(data)
|
||||||
})
|
})
|
||||||
@@ -68,26 +88,26 @@ function ExampleFormComponent({
|
|||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
gap: '1rem',
|
gap: "1rem",
|
||||||
maxWidth: '500px',
|
maxWidth: "500px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="Title/md">
|
<Typography variant="Title/md">
|
||||||
<h2>Example Form</h2>
|
<h2>Example Form</h2>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '1rem', width: '100%' }}>
|
<div style={{ display: "flex", gap: "1rem", width: "100%" }}>
|
||||||
<FormInput
|
<FormInput
|
||||||
name={getFieldName('firstName')}
|
name={getFieldName("firstName")}
|
||||||
label="First name"
|
label="First name"
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
labelPosition={labelPosition}
|
labelPosition={labelPosition}
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
<FormInput
|
<FormInput
|
||||||
name={getFieldName('lastName')}
|
name={getFieldName("lastName")}
|
||||||
label="Last name"
|
label="Last name"
|
||||||
autoComplete="family-name"
|
autoComplete="family-name"
|
||||||
labelPosition={labelPosition}
|
labelPosition={labelPosition}
|
||||||
@@ -96,7 +116,7 @@ function ExampleFormComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
name={getFieldName('email')}
|
name={getFieldName("email")}
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
@@ -105,7 +125,7 @@ function ExampleFormComponent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
name={getFieldName('phone')}
|
name={getFieldName("phone")}
|
||||||
label="Phone (optional)"
|
label="Phone (optional)"
|
||||||
type="tel"
|
type="tel"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
@@ -114,17 +134,18 @@ function ExampleFormComponent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
name={getFieldName('company')}
|
name={getFieldName("company")}
|
||||||
label="Company (optional)"
|
label="Company (optional)"
|
||||||
autoComplete="organization"
|
autoComplete="organization"
|
||||||
labelPosition={labelPosition}
|
labelPosition={labelPosition}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormInput
|
<FormTextArea
|
||||||
name={getFieldName('message')}
|
name={getFieldName("message")}
|
||||||
label="Message"
|
label={textAreaLabel || undefined}
|
||||||
labelPosition={labelPosition}
|
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
|
descriptionIcon="info"
|
||||||
|
description={textAreaDescription}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" variant="Primary" size="lg">
|
<Button type="submit" variant="Primary" size="lg">
|
||||||
@@ -136,20 +157,32 @@ function ExampleFormComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta: Meta<typeof ExampleFormComponent> = {
|
const meta: Meta<typeof ExampleFormComponent> = {
|
||||||
title: 'Compositions/Form/ExampleForm',
|
title: "Compositions/Form/ExampleForm",
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'padded',
|
layout: "padded",
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
labelPosition: {
|
labelPosition: {
|
||||||
control: 'select',
|
control: "select",
|
||||||
options: ['floating', 'top'],
|
options: ["floating", "top"],
|
||||||
description: 'Position of labels for all input fields in the form',
|
description: "Position of labels for all input fields in the form",
|
||||||
table: {
|
table: {
|
||||||
type: { summary: "'floating' | 'top'" },
|
type: { summary: "'floating' | 'top'" },
|
||||||
defaultValue: { summary: "'floating'" },
|
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 = {
|
export const Default: Story = {
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<ExampleFormComponent
|
<ExampleFormComponent
|
||||||
key={`label-${args.labelPosition || 'floating'}`}
|
key={`label-${args.labelPosition || "floating"}`}
|
||||||
{...args}
|
{...args}
|
||||||
fieldPrefix="example"
|
fieldPrefix="example"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
args: {
|
args: {
|
||||||
onSubmit: fn(),
|
onSubmit: fn(),
|
||||||
labelPosition: 'floating',
|
labelPosition: "floating",
|
||||||
|
textAreaLabel: "Message",
|
||||||
|
textAreaDescription: "This is a custom description",
|
||||||
|
textAreaError: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,46 +215,46 @@ const signupSchema = z
|
|||||||
.object({
|
.object({
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
.min(3, 'Username must be at least 3 characters')
|
.min(3, "Username must be at least 3 characters")
|
||||||
.regex(
|
.regex(
|
||||||
/^[a-z0-9_]+$/,
|
/^[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
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, "Password must be at least 8 characters")
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
.regex(/[0-9]/, "Password must contain at least one number"),
|
||||||
confirmPassword: z.string(),
|
confirmPassword: z.string(),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: 'Passwords do not match',
|
message: "Passwords do not match",
|
||||||
path: ['confirmPassword'],
|
path: ["confirmPassword"],
|
||||||
})
|
})
|
||||||
|
|
||||||
type SignupFormData = z.infer<typeof signupSchema>
|
type SignupFormData = z.infer<typeof signupSchema>
|
||||||
|
|
||||||
interface SignupFormProps {
|
interface SignupFormProps {
|
||||||
onSubmit?: (data: SignupFormData) => void
|
onSubmit?: (data: SignupFormData) => void
|
||||||
labelPosition?: 'floating' | 'top'
|
labelPosition?: "floating" | "top"
|
||||||
showErrors?: boolean
|
showErrors?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SignupFormComponent({
|
function SignupFormComponent({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
labelPosition = 'floating',
|
labelPosition = "floating",
|
||||||
showErrors = false,
|
showErrors = false,
|
||||||
}: SignupFormProps) {
|
}: SignupFormProps) {
|
||||||
const methods = useForm<SignupFormData>({
|
const methods = useForm<SignupFormData>({
|
||||||
resolver: zodResolver(signupSchema),
|
resolver: zodResolver(signupSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: showErrors ? 'A' : '',
|
username: showErrors ? "A" : "",
|
||||||
email: showErrors ? 'invalid-email' : '',
|
email: showErrors ? "invalid-email" : "",
|
||||||
password: showErrors ? 'weak' : '',
|
password: showErrors ? "weak" : "",
|
||||||
confirmPassword: showErrors ? 'nomatch' : '',
|
confirmPassword: showErrors ? "nomatch" : "",
|
||||||
},
|
},
|
||||||
mode: 'onChange',
|
mode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Trigger validation on mount if showErrors is true
|
// Trigger validation on mount if showErrors is true
|
||||||
@@ -239,10 +275,10 @@ function SignupFormComponent({
|
|||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
maxWidth: '400px',
|
maxWidth: "400px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="Title/md">
|
<Typography variant="Title/md">
|
||||||
@@ -254,7 +290,7 @@ function SignupFormComponent({
|
|||||||
label="Username"
|
label="Username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
labelPosition={labelPosition}
|
labelPosition={labelPosition}
|
||||||
description={!errors.username ? 'Username is available' : undefined}
|
description={!errors.username ? "Username is available" : undefined}
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -265,7 +301,7 @@ function SignupFormComponent({
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
labelPosition={labelPosition}
|
labelPosition={labelPosition}
|
||||||
description={
|
description={
|
||||||
!errors.email ? 'We will send a verification email' : undefined
|
!errors.email ? "We will send a verification email" : undefined
|
||||||
}
|
}
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
@@ -277,7 +313,7 @@ function SignupFormComponent({
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
labelPosition={labelPosition}
|
labelPosition={labelPosition}
|
||||||
description={
|
description={
|
||||||
!errors.password ? 'Password meets all requirements' : undefined
|
!errors.password ? "Password meets all requirements" : undefined
|
||||||
}
|
}
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
@@ -288,7 +324,7 @@ function SignupFormComponent({
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
labelPosition={labelPosition}
|
labelPosition={labelPosition}
|
||||||
description={!errors.confirmPassword ? 'Passwords match' : undefined}
|
description={!errors.confirmPassword ? "Passwords match" : undefined}
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -301,20 +337,20 @@ function SignupFormComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signupMeta: Meta<typeof SignupFormComponent> = {
|
const signupMeta: Meta<typeof SignupFormComponent> = {
|
||||||
title: 'Compositions/Form/SignupForm',
|
title: "Compositions/Form/SignupForm",
|
||||||
component: SignupFormComponent,
|
component: SignupFormComponent,
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'padded',
|
layout: "padded",
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
labelPosition: {
|
labelPosition: {
|
||||||
control: 'select',
|
control: "select",
|
||||||
options: ['floating', 'top'],
|
options: ["floating", "top"],
|
||||||
description: 'Position of the labels',
|
description: "Position of the labels",
|
||||||
},
|
},
|
||||||
showErrors: {
|
showErrors: {
|
||||||
control: 'boolean',
|
control: "boolean",
|
||||||
description: 'Show validation errors on mount',
|
description: "Show validation errors on mount",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -325,7 +361,7 @@ export const WithDescriptions: SignupStory = {
|
|||||||
render: (args) => <SignupFormComponent {...args} />,
|
render: (args) => <SignupFormComponent {...args} />,
|
||||||
args: {
|
args: {
|
||||||
onSubmit: fn(),
|
onSubmit: fn(),
|
||||||
labelPosition: 'floating',
|
labelPosition: "floating",
|
||||||
showErrors: false,
|
showErrors: false,
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -337,7 +373,7 @@ export const WithErrors: SignupStory = {
|
|||||||
render: (args) => <SignupFormComponent {...args} />,
|
render: (args) => <SignupFormComponent {...args} />,
|
||||||
args: {
|
args: {
|
||||||
onSubmit: fn(),
|
onSubmit: fn(),
|
||||||
labelPosition: 'floating',
|
labelPosition: "floating",
|
||||||
showErrors: true,
|
showErrors: true,
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -353,7 +389,7 @@ const showcaseSchema = z.object({
|
|||||||
default: z.string().optional(),
|
default: z.string().optional(),
|
||||||
placeholder: z.string().optional(),
|
placeholder: z.string().optional(),
|
||||||
filled: 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(),
|
disabled: z.string().optional(),
|
||||||
disabledFilled: z.string().optional(),
|
disabledFilled: z.string().optional(),
|
||||||
warningState: z.string().optional(),
|
warningState: z.string().optional(),
|
||||||
@@ -370,8 +406,8 @@ const showcaseSchema = z.object({
|
|||||||
searchClear: z.string().optional(),
|
searchClear: z.string().optional(),
|
||||||
clearLeftRight: z.string().optional(),
|
clearLeftRight: z.string().optional(),
|
||||||
emptyClear: z.string().optional(),
|
emptyClear: z.string().optional(),
|
||||||
error: z.string().min(10, 'Must be at least 10 characters'),
|
error: z.string().min(10, "Must be at least 10 characters"),
|
||||||
errorFilled: z.string().email('Invalid email'),
|
errorFilled: z.string().email("Invalid email"),
|
||||||
warning: z.string().optional(),
|
warning: z.string().optional(),
|
||||||
warningFilledValidation: z.string().optional(),
|
warningFilledValidation: z.string().optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
@@ -381,7 +417,7 @@ const showcaseSchema = z.object({
|
|||||||
passwordType: z.string().optional(),
|
passwordType: z.string().optional(),
|
||||||
urlType: z.string().optional(),
|
urlType: z.string().optional(),
|
||||||
combined1: z.string().optional(),
|
combined1: z.string().optional(),
|
||||||
combined2: z.string().email('Invalid email'),
|
combined2: z.string().email("Invalid email"),
|
||||||
combined3: z.string().optional(),
|
combined3: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -391,35 +427,35 @@ function InputShowcase() {
|
|||||||
const methods = useForm<ShowcaseFormData>({
|
const methods = useForm<ShowcaseFormData>({
|
||||||
resolver: zodResolver(showcaseSchema),
|
resolver: zodResolver(showcaseSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
filled: 'Sample text',
|
filled: "Sample text",
|
||||||
disabledFilled: 'Cannot edit',
|
disabledFilled: "Cannot edit",
|
||||||
warningFilled: 'Needs attention',
|
warningFilled: "Needs attention",
|
||||||
emailClear: 'user@example.com',
|
emailClear: "user@example.com",
|
||||||
searchClear: '',
|
searchClear: "",
|
||||||
clearLeftRight: '+46 70 123 45 67',
|
clearLeftRight: "+46 70 123 45 67",
|
||||||
error: 'Short',
|
error: "Short",
|
||||||
errorFilled: 'Invalid input',
|
errorFilled: "Invalid input",
|
||||||
warningFilledValidation: 'Needs attention',
|
warningFilledValidation: "Needs attention",
|
||||||
combined1: 'user@example.com',
|
combined1: "user@example.com",
|
||||||
combined2: 'Invalid email',
|
combined2: "Invalid email",
|
||||||
},
|
},
|
||||||
mode: 'onChange',
|
mode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Trigger validation for error examples on mount
|
// Trigger validation for error examples on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
methods.trigger(['error', 'errorFilled', 'combined2'])
|
methods.trigger(["error", "errorFilled", "combined2"])
|
||||||
}, [methods])
|
}, [methods])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
gap: '3rem',
|
gap: "3rem",
|
||||||
maxWidth: '800px',
|
maxWidth: "800px",
|
||||||
padding: '2rem',
|
padding: "2rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="Title/lg">
|
<Typography variant="Title/lg">
|
||||||
@@ -433,10 +469,10 @@ function InputShowcase() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: "grid",
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
marginTop: '1rem',
|
marginTop: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormInput name="default" label="Default" />
|
<FormInput name="default" label="Default" />
|
||||||
@@ -485,10 +521,10 @@ function InputShowcase() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: "grid",
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
marginTop: '1rem',
|
marginTop: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -516,10 +552,10 @@ function InputShowcase() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: "grid",
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
marginTop: '1rem',
|
marginTop: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -558,10 +594,10 @@ function InputShowcase() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: "grid",
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
marginTop: '1rem',
|
marginTop: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -606,10 +642,10 @@ function InputShowcase() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: "grid",
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
marginTop: '1rem',
|
marginTop: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -642,10 +678,10 @@ function InputShowcase() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: "grid",
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
marginTop: '1rem',
|
marginTop: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormInput name="text" label="Text" type="text" />
|
<FormInput name="text" label="Text" type="text" />
|
||||||
@@ -659,10 +695,10 @@ function InputShowcase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showcaseMeta: Meta<typeof InputShowcase> = {
|
const showcaseMeta: Meta<typeof InputShowcase> = {
|
||||||
title: 'Compositions/Form/InputShowcase',
|
title: "Compositions/Form/InputShowcase",
|
||||||
component: InputShowcase,
|
component: InputShowcase,
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'fullscreen',
|
layout: "fullscreen",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { forwardRef } 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 { TextArea } from "../../TextArea"
|
||||||
|
|
||||||
|
import styles from "./textarea.module.css"
|
||||||
|
|
||||||
|
import type { FormTextAreaProps } from "./textarea"
|
||||||
|
import { MaterialIcon, MaterialIconProps } from "../../Icons/MaterialIcon"
|
||||||
|
|
||||||
|
const defaultErrorFormatter = (
|
||||||
|
_intl: IntlShape,
|
||||||
|
errorMessage?: string
|
||||||
|
): string => errorMessage ?? ""
|
||||||
|
|
||||||
|
export const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
|
||||||
|
function FormTextArea(
|
||||||
|
{
|
||||||
|
className = "",
|
||||||
|
description = "",
|
||||||
|
descriptionIcon = "info" as MaterialIconProps["icon"],
|
||||||
|
disabled = false,
|
||||||
|
errorFormatter,
|
||||||
|
hideError,
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
readOnly = false,
|
||||||
|
registerOptions = {},
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const { control } = useFormContext()
|
||||||
|
|
||||||
|
const formatErrorMessage = errorFormatter ?? defaultErrorFormatter
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<TextField
|
||||||
|
className={cx(styles.wrapper, className)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isReadOnly={readOnly}
|
||||||
|
isInvalid={fieldState.invalid}
|
||||||
|
isRequired={!!registerOptions.required}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...props}
|
||||||
|
ref={mergeRefs(field.ref, ref)}
|
||||||
|
name={field.name}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
onChange={field.onChange}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
id={field.name}
|
||||||
|
label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={isDisabled}
|
||||||
|
required={!!registerOptions.required}
|
||||||
|
/>
|
||||||
|
{showDescription ? (
|
||||||
|
<Text className={styles.description} slot="description">
|
||||||
|
<MaterialIcon icon={descriptionIcon} size={20} />
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{hasError && fieldState.error ? (
|
||||||
|
<Text slot="errorMessage" aria-live="polite">
|
||||||
|
<Error>
|
||||||
|
{formatErrorMessage(intl, fieldState.error.message)}
|
||||||
|
</Error>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FormTextArea.displayName = "FormTextArea"
|
||||||
|
|
||||||
|
// Default export for backwards compatibility
|
||||||
|
export default FormTextArea
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,20 @@
|
|||||||
|
import type { RegisterOptions } from "react-hook-form"
|
||||||
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
|
import type { TextAreaProps } from "../../TextArea/types"
|
||||||
|
import { MaterialIconProps } from "../../Icons/MaterialIcon"
|
||||||
|
|
||||||
|
export interface FormTextAreaProps extends TextAreaProps {
|
||||||
|
/** Helper text displayed below the textarea (hidden when there's an error) */
|
||||||
|
description?: string
|
||||||
|
/** Icon to display with the description text. Defaults to 'info' */
|
||||||
|
descriptionIcon?: MaterialIconProps["icon"]
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Label as AriaLabel,
|
|
||||||
Text,
|
|
||||||
TextArea as AriaTextArea,
|
|
||||||
TextField,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { Controller, useFormContext } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
|
||||||
import { InputLabel } from '../../InputLabel'
|
|
||||||
import { Typography } from '../../Typography'
|
|
||||||
|
|
||||||
import styles from './textarea.module.css'
|
|
||||||
|
|
||||||
import type { TextAreaProps } from './input'
|
|
||||||
|
|
||||||
export default function TextArea({
|
|
||||||
'aria-label': ariaLabel,
|
|
||||||
className = '',
|
|
||||||
disabled = false,
|
|
||||||
helpText = '',
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
placeholder = '',
|
|
||||||
readOnly = false,
|
|
||||||
registerOptions = {},
|
|
||||||
}: TextAreaProps) {
|
|
||||||
const { control } = useFormContext()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
disabled={disabled}
|
|
||||||
control={control}
|
|
||||||
name={name}
|
|
||||||
rules={registerOptions}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<TextField
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className={className}
|
|
||||||
isDisabled={field.disabled}
|
|
||||||
isRequired={!!registerOptions.required}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
onChange={field.onChange}
|
|
||||||
validationBehavior="aria"
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<AriaLabel className={styles.container}>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<AriaTextArea
|
|
||||||
{...field}
|
|
||||||
aria-labelledby={field.name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
readOnly={readOnly}
|
|
||||||
required={!!registerOptions.required}
|
|
||||||
className={styles.textarea}
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
<InputLabel required={!!registerOptions.required}>
|
|
||||||
{label}
|
|
||||||
</InputLabel>
|
|
||||||
</AriaLabel>
|
|
||||||
{helpText && !fieldState.error ? (
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
|
||||||
<Text className={styles.helpText} slot="description">
|
|
||||||
<MaterialIcon icon="check" size={30} />
|
|
||||||
{helpText}
|
|
||||||
</Text>
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{fieldState.error ? (
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
|
||||||
<p className={styles.error}>
|
|
||||||
<MaterialIcon icon="info" color="Icon/Interactive/Accent" />
|
|
||||||
{fieldState.error.message}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</TextField>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { RegisterOptions } from 'react-hook-form'
|
|
||||||
|
|
||||||
export interface TextAreaProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLTextAreaElement> {
|
|
||||||
helpText?: string
|
|
||||||
label: string
|
|
||||||
name: string
|
|
||||||
registerOptions?: RegisterOptions
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
.helpText {
|
|
||||||
align-items: flex-start;
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Space-x05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
align-items: center;
|
|
||||||
color: var(--Scandic-Red-60);
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Space-x05);
|
|
||||||
margin: var(--Space-x1) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
background-color: var(--Main-Grey-White);
|
|
||||||
border-color: var(--Scandic-Beige-40);
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: var(--Corner-radius-md);
|
|
||||||
display: grid;
|
|
||||||
min-width: 0; /* allow shrinkage */
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
height: 138px;
|
|
||||||
padding: var(--Space-x3) var(--Space-x2) 0 var(--Space-x2);
|
|
||||||
transition: border-color 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container:has(.textarea:active, .textarea:focus) {
|
|
||||||
border-color: var(--Scandic-Blue-90);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container:has(.textarea:disabled) {
|
|
||||||
background-color: var(--Main-Grey-10);
|
|
||||||
border: none;
|
|
||||||
color: var(--Main-Grey-40);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container:has(.textarea[data-invalid='true'], .textarea[aria-invalid='true']) {
|
|
||||||
border-color: var(--Scandic-Red-60);
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--Main-Grey-100);
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
order: 2;
|
|
||||||
overflow: visible;
|
|
||||||
padding: 0;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea:not(:active, :focus):placeholder-shown {
|
|
||||||
height: 88px;
|
|
||||||
transition: height 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea:focus,
|
|
||||||
.textarea:focus:placeholder-shown,
|
|
||||||
.textarea:active,
|
|
||||||
.textarea:active:placeholder-shown {
|
|
||||||
height: 94px;
|
|
||||||
transition: height 150ms ease;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea:disabled {
|
|
||||||
color: var(--Main-Grey-40);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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 { z } from "zod"
|
||||||
|
|
||||||
|
import { FormTextArea } from "../Form/FormTextArea"
|
||||||
|
import type { MaterialIconProps } from "../Icons/MaterialIcon"
|
||||||
|
|
||||||
|
interface FormTextAreaStoryProps {
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
description?: string
|
||||||
|
descriptionIcon?: MaterialIconProps["icon"]
|
||||||
|
disabled?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
|
required?: boolean
|
||||||
|
showLabel?: boolean
|
||||||
|
showDescription?: boolean
|
||||||
|
showDescriptionIcon?: boolean
|
||||||
|
filled?: boolean
|
||||||
|
defaultValue?: string
|
||||||
|
errorMessage?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormTextAreaComponent({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
description,
|
||||||
|
descriptionIcon = "info",
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
required = false,
|
||||||
|
showLabel = true,
|
||||||
|
showDescription = true,
|
||||||
|
showDescriptionIcon = true,
|
||||||
|
filled = false,
|
||||||
|
defaultValue = "",
|
||||||
|
errorMessage,
|
||||||
|
name = "textarea",
|
||||||
|
}: FormTextAreaStoryProps) {
|
||||||
|
const schema = z.object({
|
||||||
|
[name]: errorMessage
|
||||||
|
? z.string().min(1, errorMessage)
|
||||||
|
: required
|
||||||
|
? z.string().min(1, "This field is required")
|
||||||
|
: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const methods = useForm<{ [key: string]: string }>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
[name]: filled ? defaultValue : "",
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorMessage) {
|
||||||
|
// Set error manually if errorMessage is provided
|
||||||
|
methods.setError(name, {
|
||||||
|
type: "manual",
|
||||||
|
message: errorMessage,
|
||||||
|
})
|
||||||
|
// Trigger validation to show the error immediately
|
||||||
|
methods.trigger(name)
|
||||||
|
} else {
|
||||||
|
// Clear errors if errorMessage is removed
|
||||||
|
methods.clearErrors(name)
|
||||||
|
}
|
||||||
|
}, [errorMessage, methods, name])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<div style={{ maxWidth: "500px", padding: "1rem" }}>
|
||||||
|
<FormTextArea
|
||||||
|
name={name}
|
||||||
|
label={showLabel ? label : undefined}
|
||||||
|
placeholder={placeholder}
|
||||||
|
description={showDescription ? description : undefined}
|
||||||
|
descriptionIcon={showDescriptionIcon ? descriptionIcon : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
registerOptions={required ? { required: true } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof FormTextAreaComponent> = {
|
||||||
|
title: "Core Components/TextArea",
|
||||||
|
component: FormTextAreaComponent,
|
||||||
|
argTypes: {
|
||||||
|
label: {
|
||||||
|
control: "text",
|
||||||
|
description: "The label text displayed above the textarea field",
|
||||||
|
table: {
|
||||||
|
type: { summary: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
control: "text",
|
||||||
|
description: "Placeholder text shown when textarea is empty",
|
||||||
|
table: {
|
||||||
|
type: { summary: "string" },
|
||||||
|
defaultValue: { summary: "undefined" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
control: "text",
|
||||||
|
description: "Supporting text displayed below the textarea",
|
||||||
|
table: {
|
||||||
|
type: { summary: "string" },
|
||||||
|
defaultValue: { summary: "undefined" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
disabled: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether the textarea is disabled",
|
||||||
|
table: {
|
||||||
|
type: { summary: "boolean" },
|
||||||
|
defaultValue: { summary: "false" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether the textarea is read-only",
|
||||||
|
table: {
|
||||||
|
type: { summary: "boolean" },
|
||||||
|
defaultValue: { summary: "false" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether the textarea is required",
|
||||||
|
table: {
|
||||||
|
type: { summary: "boolean" },
|
||||||
|
defaultValue: { summary: "false" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showLabel: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether to show the label",
|
||||||
|
table: {
|
||||||
|
type: { summary: "boolean" },
|
||||||
|
defaultValue: { summary: "true" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showDescription: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether to show the description/supporting text",
|
||||||
|
table: {
|
||||||
|
type: { summary: "boolean" },
|
||||||
|
defaultValue: { summary: "true" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: {
|
||||||
|
control: "text",
|
||||||
|
description: "Default value when filled is true",
|
||||||
|
table: {
|
||||||
|
type: { summary: "string" },
|
||||||
|
defaultValue: { summary: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
control: "text",
|
||||||
|
description: "Error message to display (triggers error state)",
|
||||||
|
table: {
|
||||||
|
type: { summary: "string" },
|
||||||
|
defaultValue: { summary: "undefined" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof FormTextAreaComponent>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
label: "Label",
|
||||||
|
placeholder: "Placeholder",
|
||||||
|
description: "Supporting text",
|
||||||
|
disabled: false,
|
||||||
|
readOnly: false,
|
||||||
|
required: false,
|
||||||
|
showLabel: true,
|
||||||
|
showDescription: true,
|
||||||
|
defaultValue: "",
|
||||||
|
errorMessage: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
120
packages/design-system/lib/components/TextArea/TextArea.test.tsx
Normal file
120
packages/design-system/lib/components/TextArea/TextArea.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from "vitest"
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react"
|
||||||
|
import userEvent from "@testing-library/user-event"
|
||||||
|
import { TextArea } from "./TextArea"
|
||||||
|
import { TextField } from "react-aria-components"
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap TextArea in TextField for proper React Aria context
|
||||||
|
const renderTextArea = (props: React.ComponentProps<typeof TextArea>) => {
|
||||||
|
return render(
|
||||||
|
<TextField>
|
||||||
|
<TextArea {...props} />
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render TextArea standalone for testing its own behavior
|
||||||
|
const renderTextAreaStandalone = (
|
||||||
|
props: React.ComponentProps<typeof TextArea>
|
||||||
|
) => {
|
||||||
|
return render(<TextArea {...props} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TextArea", () => {
|
||||||
|
describe("props", () => {
|
||||||
|
it("applies required attribute", () => {
|
||||||
|
renderTextArea({ label: "Description", required: true })
|
||||||
|
expect(screen.getByRole("textbox")).toHaveProperty("required", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies placeholder when provided", () => {
|
||||||
|
renderTextArea({ label: "Description", placeholder: "Enter description" })
|
||||||
|
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe(
|
||||||
|
"Enter description"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies empty placeholder by default", () => {
|
||||||
|
renderTextArea({ label: "Description" })
|
||||||
|
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies custom id", () => {
|
||||||
|
renderTextAreaStandalone({ label: "Description", id: "custom-id" })
|
||||||
|
expect(screen.getByRole("textbox").getAttribute("id")).toBe("custom-id")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("label", () => {
|
||||||
|
it("renders label when provided", () => {
|
||||||
|
renderTextArea({ label: "Description" })
|
||||||
|
expect(screen.getByText("Description")).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not render label when not provided", () => {
|
||||||
|
renderTextArea({})
|
||||||
|
expect(screen.queryByText("Description")).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("controlled textarea", () => {
|
||||||
|
it("displays the controlled value", () => {
|
||||||
|
renderTextArea({
|
||||||
|
label: "Description",
|
||||||
|
value: "Some text content",
|
||||||
|
onChange: vi.fn(),
|
||||||
|
})
|
||||||
|
expect(screen.getByRole("textbox")).toHaveProperty(
|
||||||
|
"value",
|
||||||
|
"Some text content"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("calls onChange when typing", async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
renderTextArea({ label: "Description", value: "", onChange })
|
||||||
|
|
||||||
|
const textarea = screen.getByRole("textbox")
|
||||||
|
await userEvent.type(textarea, "a")
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not change value without onChange updating it", () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
renderTextArea({ label: "Description", value: "initial", onChange })
|
||||||
|
|
||||||
|
const textarea = screen.getByRole("textbox")
|
||||||
|
fireEvent.change(textarea, { target: { value: "changed" } })
|
||||||
|
|
||||||
|
expect(textarea).toHaveProperty("value", "initial")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("ref forwarding", () => {
|
||||||
|
it("forwards ref to the textarea element", () => {
|
||||||
|
const ref = { current: null as HTMLTextAreaElement | null }
|
||||||
|
render(
|
||||||
|
<TextField>
|
||||||
|
<TextArea label="Description" ref={ref} />
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows focusing via ref", () => {
|
||||||
|
const ref = { current: null as HTMLTextAreaElement | null }
|
||||||
|
render(
|
||||||
|
<TextField>
|
||||||
|
<TextArea label="Description" ref={ref} />
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
ref.current?.focus()
|
||||||
|
expect(document.activeElement).toBe(ref.current)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
43
packages/design-system/lib/components/TextArea/TextArea.tsx
Normal file
43
packages/design-system/lib/components/TextArea/TextArea.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { cx } from "class-variance-authority"
|
||||||
|
import { forwardRef, type ForwardedRef, useId } from "react"
|
||||||
|
import { TextArea as AriaTextArea } from "react-aria-components"
|
||||||
|
|
||||||
|
import { InputLabel } from "../InputLabel"
|
||||||
|
import { Typography } from "../Typography"
|
||||||
|
|
||||||
|
import styles from "./textarea.module.css"
|
||||||
|
import type { TextAreaProps } from "./types"
|
||||||
|
|
||||||
|
const TextAreaComponent = forwardRef(function TextAreaComponent(
|
||||||
|
{ label, placeholder, id, required, ...props }: TextAreaProps,
|
||||||
|
ref: ForwardedRef<HTMLTextAreaElement>
|
||||||
|
) {
|
||||||
|
const generatedId = useId()
|
||||||
|
const textareaId = id || generatedId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<InputLabel required={required} className={styles.labelAbove}>
|
||||||
|
{label}
|
||||||
|
</InputLabel>
|
||||||
|
)}
|
||||||
|
<label className={styles.container} htmlFor={textareaId}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<AriaTextArea
|
||||||
|
{...props}
|
||||||
|
id={textareaId}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder ?? ""}
|
||||||
|
className={cx(styles.textarea, props.className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TextArea = TextAreaComponent as React.ForwardRefExoticComponent<
|
||||||
|
TextAreaProps & React.RefAttributes<HTMLTextAreaElement>
|
||||||
|
>
|
||||||
2
packages/design-system/lib/components/TextArea/index.tsx
Normal file
2
packages/design-system/lib/components/TextArea/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { TextArea } from "./TextArea"
|
||||||
|
export type { TextAreaProps } from "./types"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.labelAbove {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
||||||
|
font-size: var(--Body-Supporting-text-Size);
|
||||||
|
font-weight: var(--Body-Supporting-text-Font-weight-2);
|
||||||
|
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: var(--Surface-Primary-Default);
|
||||||
|
border: 1px solid var(--Border-Interactive-Default);
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
height: 138px;
|
||||||
|
padding: var(--Space-x2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
&:has(.textarea:focus):not(:has(.textarea:disabled)):not(
|
||||||
|
:has(.textarea:read-only)
|
||||||
|
):not(:has(.textarea[data-invalid="true"])):not(
|
||||||
|
:has(.textarea[aria-invalid="true"])
|
||||||
|
) {
|
||||||
|
outline-offset: -2px;
|
||||||
|
outline: 2px solid var(--Border-Interactive-Focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.textarea:disabled),
|
||||||
|
&:has(.textarea:read-only) {
|
||||||
|
background-color: var(--Surface-Primary-Disabled);
|
||||||
|
border: transparent;
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.textarea[data-invalid="true"], .textarea[aria-invalid="true"]) {
|
||||||
|
border-color: var(--Border-Interactive-Error);
|
||||||
|
|
||||||
|
&:focus-within,
|
||||||
|
&:has(.textarea:focus) {
|
||||||
|
outline-offset: -2px;
|
||||||
|
outline: 2px solid var(--Border-Interactive-Error);
|
||||||
|
border-color: var(--Border-Interactive-Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--Text-Default);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
resize: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled,
|
||||||
|
&:read-only {
|
||||||
|
color: var(--Text-Interactive-Disabled);
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/design-system/lib/components/TextArea/types.ts
Normal file
7
packages/design-system/lib/components/TextArea/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ComponentProps } from "react"
|
||||||
|
import { TextArea } from "react-aria-components"
|
||||||
|
|
||||||
|
export interface TextAreaProps extends ComponentProps<typeof TextArea> {
|
||||||
|
/** Optional label displayed above the textarea */
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
const config = {
|
const config = {
|
||||||
'*.{ts,tsx}': [
|
"*.{ts,tsx}": [
|
||||||
() => 'yarn lint',
|
() => "yarn lint",
|
||||||
() => 'yarn check-types',
|
() => "yarn check-types",
|
||||||
'prettier --write',
|
"prettier --write",
|
||||||
],
|
],
|
||||||
'*.{json,md}': 'prettier --write',
|
"*.{json,md}": "prettier --write",
|
||||||
'*.{html,js,cjs,mjs,css}': 'prettier --write',
|
"*.{html,js,cjs,mjs,css}": "prettier --write",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"./Form/Phone": "./lib/components/Form/Phone/index.tsx",
|
"./Form/Phone": "./lib/components/Form/Phone/index.tsx",
|
||||||
"./Form/RadioCard": "./lib/components/Form/RadioCard/index.tsx",
|
"./Form/RadioCard": "./lib/components/Form/RadioCard/index.tsx",
|
||||||
"./Form/SelectPaymentMethod": "./lib/components/Form/SelectPaymentMethod/index.tsx",
|
"./Form/SelectPaymentMethod": "./lib/components/Form/SelectPaymentMethod/index.tsx",
|
||||||
"./Form/TextArea": "./lib/components/Form/TextArea/index.tsx",
|
"./Form/FormTextArea": "./lib/components/Form/FormTextArea/index.tsx",
|
||||||
"./HotelCard": "./lib/components/HotelCard/index.tsx",
|
"./HotelCard": "./lib/components/HotelCard/index.tsx",
|
||||||
"./HotelCard/HotelCardDialogImage": "./lib/components/HotelCard/HotelCardDialogImage/index.tsx",
|
"./HotelCard/HotelCardDialogImage": "./lib/components/HotelCard/HotelCardDialogImage/index.tsx",
|
||||||
"./HotelCard/HotelCardSkeleton": "./lib/components/HotelCard/HotelCardSkeleton.tsx",
|
"./HotelCard/HotelCardSkeleton": "./lib/components/HotelCard/HotelCardSkeleton.tsx",
|
||||||
@@ -178,6 +178,7 @@
|
|||||||
"./Subtitle": "./lib/components/Subtitle/index.tsx",
|
"./Subtitle": "./lib/components/Subtitle/index.tsx",
|
||||||
"./Switch": "./lib/components/Switch/index.tsx",
|
"./Switch": "./lib/components/Switch/index.tsx",
|
||||||
"./Table": "./lib/components/Table/index.tsx",
|
"./Table": "./lib/components/Table/index.tsx",
|
||||||
|
"./TextArea": "./lib/components/TextArea/index.tsx",
|
||||||
"./TextLink": "./lib/components/TextLink/index.tsx",
|
"./TextLink": "./lib/components/TextLink/index.tsx",
|
||||||
"./TextLinkButton": "./lib/components/TextLinkButton/index.tsx",
|
"./TextLinkButton": "./lib/components/TextLinkButton/index.tsx",
|
||||||
"./Title": "./lib/components/Title/index.tsx",
|
"./Title": "./lib/components/Title/index.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user