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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
'*.{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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user