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

@@ -3,7 +3,7 @@ import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
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 { Typography } from "@scandic-hotels/design-system/Typography"

View File

@@ -1,8 +1,8 @@
import { useIntl } from "react-intl"
import TextArea from "@scandic-hotels/design-system/Form/TextArea"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FormTextArea } from "@scandic-hotels/design-system/Form/FormTextArea"
import styles from "./specialRequests.module.css"
import type { RegisterOptions } from "react-hook-form"
@@ -63,7 +63,7 @@ export function SpecialRequests({
},
]}
/> */}
<TextArea
<FormTextArea
label={intl.formatMessage({
id: "enterDetails.specialRequests.commentLabel",
defaultMessage:

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",
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
})
})
})

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

View File

@@ -0,0 +1,2 @@
export { TextArea } from "./TextArea"
export type { TextAreaProps } from "./types"

View File

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

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

View File

@@ -1,11 +1,11 @@
const config = {
'*.{ts,tsx}': [
() => 'yarn lint',
() => 'yarn check-types',
'prettier --write',
"*.{ts,tsx}": [
() => "yarn lint",
() => "yarn check-types",
"prettier --write",
],
'*.{json,md}': 'prettier --write',
'*.{html,js,cjs,mjs,css}': 'prettier --write',
"*.{json,md}": "prettier --write",
"*.{html,js,cjs,mjs,css}": "prettier --write",
}
export default config

View File

@@ -38,7 +38,7 @@
"./Form/Phone": "./lib/components/Form/Phone/index.tsx",
"./Form/RadioCard": "./lib/components/Form/RadioCard/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/HotelCardDialogImage": "./lib/components/HotelCard/HotelCardDialogImage/index.tsx",
"./HotelCard/HotelCardSkeleton": "./lib/components/HotelCard/HotelCardSkeleton.tsx",
@@ -178,6 +178,7 @@
"./Subtitle": "./lib/components/Subtitle/index.tsx",
"./Switch": "./lib/components/Switch/index.tsx",
"./Table": "./lib/components/Table/index.tsx",
"./TextArea": "./lib/components/TextArea/index.tsx",
"./TextLink": "./lib/components/TextLink/index.tsx",
"./TextLinkButton": "./lib/components/TextLinkButton/index.tsx",
"./Title": "./lib/components/Title/index.tsx",