Merged in fix/3693-group-inputs-storybook (pull request #3402)

fix(SW-3693): Refactor Input stories to use FormInput component and showcase all controls

* Refactor Input stories to use FormInput component and showcase all controls

* Updated stories and added autocomplete prop to PasswordInput

* Merge branch 'master' into fix/3693-group-inputs-storybook

* Use FormTextArea in stories for TextArea to show description and error texts

* Merged master into fix/3693-group-inputs-storybook

* Merge branch 'master' into fix/3693-group-inputs-storybook

* Removed redundant font name and fixed broken icons in stories

* Merge branch 'fix/3693-group-inputs-storybook' of bitbucket.org:scandic-swap/web into fix/3693-group-inputs-storybook

* Merged master into fix/3693-group-inputs-storybook

* Merge branch 'master' into fix/3693-group-inputs-storybook


Approved-by: Bianca Widstam
This commit is contained in:
Rasmus Langvad
2026-01-21 16:20:04 +00:00
parent 993f6c4377
commit b966cf1d53
9 changed files with 1219 additions and 673 deletions

View File

@@ -6,7 +6,6 @@ import { fn } from "storybook/test"
import { z } from "zod" import { z } from "zod"
import { Button } from "../../Button" import { Button } from "../../Button"
import { MaterialIcon } from "../../Icons/MaterialIcon"
import { Typography } from "../../Typography" import { Typography } from "../../Typography"
import { FormInput } from "../FormInput" import { FormInput } from "../FormInput"
@@ -376,333 +375,3 @@ export const WithErrors: SignupStory = {
...signupMeta.parameters, ...signupMeta.parameters,
}, },
} }
// ============================================================================
// Input Variations Showcase
// ============================================================================
const showcaseSchema = z.object({
default: z.string().optional(),
placeholder: z.string().optional(),
filled: z.string().optional(),
required: z.string().min(1, "This field is required"),
disabled: z.string().optional(),
disabledFilled: z.string().optional(),
warningState: z.string().optional(),
warningFilled: z.string().optional(),
emailIcon: z.string().optional(),
searchIcon: z.string().optional(),
locked: z.string().optional(),
bothIcons: z.string().optional(),
emailIconTop: z.string().optional(),
searchIconTop: z.string().optional(),
lockedTop: z.string().optional(),
bothIconsTop: z.string().optional(),
emailClear: z.string().optional(),
searchClear: z.string().optional(),
clearLeftRight: z.string().optional(),
emptyClear: z.string().optional(),
error: z.string().min(10, "Must be at least 10 characters"),
errorFilled: z.string().email("Invalid email"),
warning: z.string().optional(),
warningFilledValidation: z.string().optional(),
text: z.string().optional(),
emailType: z.string().optional(),
telType: z.string().optional(),
number: z.string().optional(),
passwordType: z.string().optional(),
urlType: z.string().optional(),
combined1: z.string().optional(),
combined2: z.string().email("Invalid email"),
combined3: z.string().optional(),
})
type ShowcaseFormData = z.infer<typeof showcaseSchema>
function InputShowcase() {
const methods = useForm<ShowcaseFormData>({
resolver: zodResolver(showcaseSchema),
defaultValues: {
filled: "Sample text",
disabledFilled: "Cannot edit",
warningFilled: "Needs attention",
emailClear: "user@example.com",
searchClear: "",
clearLeftRight: "+46 70 123 45 67",
error: "Short",
errorFilled: "Invalid input",
warningFilledValidation: "Needs attention",
combined1: "user@example.com",
combined2: "Invalid email",
},
mode: "onChange",
})
// Trigger validation for error examples on mount
useEffect(() => {
methods.trigger(["error", "errorFilled", "combined2"])
}, [methods])
return (
<FormProvider {...methods}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "3rem",
maxWidth: "800px",
padding: "2rem",
}}
>
<Typography variant="Title/lg">
<h1>FormInput Component Showcase</h1>
</Typography>
{/* Basic States */}
<section>
<Typography variant="Title/md">
<h2>Basic States</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput name="default" label="Default" />
<FormInput
name="placeholder"
label="Placeholder"
placeholder="Enter text here..."
/>
<FormInput
name="default2"
label="Default"
description="This is a default input with a description"
/>
<FormInput
name="default3"
label="Default"
placeholder="Enter text here..."
description="This is a default input with a description"
/>
<FormInput name="filled" label="Filled" />
<FormInput
name="required"
label="Required"
registerOptions={{ required: true }}
hideError
/>
<FormInput name="disabled" label="Disabled" disabled />
<FormInput name="disabledFilled" label="Disabled Filled" disabled />
<FormInput
name="warningState"
label="Warning State"
validationState="warning"
/>
<FormInput
name="warningFilled"
label="Warning with Value"
validationState="warning"
/>
</div>
</section>
{/* With Icons */}
<section>
<Typography variant="Title/md">
<h2>With Icons</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
name="emailIcon"
label="Email"
type="email"
leftIcon={<MaterialIcon icon="mail" />}
/>
<FormInput
name="searchIcon"
label="Search"
leftIcon={<MaterialIcon icon="search" />}
/>
<FormInput
name="locked"
label="Locked icon"
rightIcon={<MaterialIcon icon="lock" />}
/>
<FormInput
name="bothIcons"
label="With Both Icons"
leftIcon={<MaterialIcon icon="person" />}
rightIcon={<MaterialIcon icon="check_circle" />}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
name="emailIconTop"
label="Email"
type="email"
leftIcon={<MaterialIcon icon="mail" />}
labelPosition="top"
/>
<FormInput
name="searchIconTop"
label="Search"
leftIcon={<MaterialIcon icon="search" />}
labelPosition="top"
/>
<FormInput
name="lockedTop"
label="Locked icon"
rightIcon={<MaterialIcon icon="lock" />}
labelPosition="top"
/>
<FormInput
name="bothIconsTop"
label="With Both Icons"
leftIcon={<MaterialIcon icon="person" />}
rightIcon={<MaterialIcon icon="check_circle" />}
labelPosition="top"
/>
</div>
</section>
{/* Clear Button */}
<section>
<Typography variant="Title/md">
<h2>Clear Button</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
name="emailClear"
label="Email with Clear"
type="email"
showClearContentIcon
/>
<FormInput
name="searchClear"
label="Search with Clear"
leftIcon={<MaterialIcon icon="search" />}
showClearContentIcon
/>
<FormInput
name="clearLeftRight"
label="Clear with Left Icon"
leftIcon={<MaterialIcon icon="call" />}
showClearContentIcon
/>
<FormInput
name="clearRightIcon"
label="Clear with Right Icon"
rightIcon={<MaterialIcon icon="lock" />}
showClearContentIcon
/>
<FormInput
name="clearBothIcons"
label="Clear with Both Icon"
leftIcon={<MaterialIcon icon="mail" />}
rightIcon={<MaterialIcon icon="lock" />}
showClearContentIcon
/>
<FormInput name="emptyClear" label="Empty" showClearContentIcon />
</div>
</section>
{/* Validation States */}
<section>
<Typography variant="Title/md">
<h2>Validation States</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
name="error"
label="Error State"
registerOptions={{ minLength: 10 }}
/>
<FormInput
name="errorFilled"
label="Error with Value"
registerOptions={{ required: true }}
/>
<FormInput
name="warning"
label="Warning State"
validationState="warning"
/>
<FormInput
name="warningFilledValidation"
label="Warning with Value"
validationState="warning"
/>
</div>
</section>
{/* Input Types */}
<section>
<Typography variant="Title/md">
<h2>Input Types</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput name="text" label="Text" type="text" />
<FormInput name="emailType" label="Email" type="email" />
<FormInput name="number" label="Number" type="number" />
</div>
</section>
</div>
</FormProvider>
)
}
const showcaseMeta: Meta<typeof InputShowcase> = {
title: "Compositions/Form/InputShowcase",
component: InputShowcase,
parameters: {
layout: "fullscreen",
},
}
type ShowcaseStory = StoryObj<typeof InputShowcase>
export const AllVariations: ShowcaseStory = {
render: () => <InputShowcase />,
parameters: {
...showcaseMeta.parameters,
},
}

View File

@@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod"
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { FormProvider, useForm } from "react-hook-form"
import { z } from "zod"
import type { IntlShape } from "react-intl"
import { PasswordInput } from "../../PasswordInput"
import { Button } from "../../Button"
import { Typography } from "../../Typography"
import { passwordValidator } from "@scandic-hotels/common/utils/zod/passwordValidator"
// Simple error formatter for Storybook
const defaultErrorFormatter = (
_intl: IntlShape,
errorMessage?: string
): string => errorMessage ?? ""
const passwordFormSchema = z
.object({
currentPassword: z.string().optional(),
newPassword: z.literal("").optional().or(passwordValidator()),
confirmPassword: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.newPassword && data.newPassword !== data.confirmPassword) {
ctx.addIssue({
code: "custom",
message: "Passwords do not match",
path: ["confirmPassword"],
})
}
})
type PasswordFormData = z.infer<typeof passwordFormSchema>
interface PasswordFormProps {
onSubmit?: (data: PasswordFormData) => void
}
function PasswordFormComponent({ onSubmit }: PasswordFormProps) {
const methods = useForm<PasswordFormData>({
resolver: zodResolver(passwordFormSchema),
defaultValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
mode: "all",
criteriaMode: "all",
reValidateMode: "onChange",
})
const handleSubmit = methods.handleSubmit((data) => {
onSubmit?.(data)
})
return (
<FormProvider {...methods}>
<form
onSubmit={handleSubmit}
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
maxWidth: "500px",
}}
>
<Typography variant="Title/md">
<h2>Change Password</h2>
</Typography>
<PasswordInput
name="currentPassword"
label="Current password"
errorFormatter={defaultErrorFormatter}
/>
<PasswordInput
name="newPassword"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
<PasswordInput
name="confirmPassword"
label="Confirm new password"
errorFormatter={defaultErrorFormatter}
/>
<Button type="submit" variant="Primary" size="lg">
Update password
</Button>
</form>
</FormProvider>
)
}
const meta: Meta<typeof PasswordFormComponent> = {
title: "Compositions/Form/PasswordForm",
component: PasswordFormComponent,
parameters: {
layout: "padded",
},
}
export default meta
type Story = StoryObj<typeof PasswordFormComponent>
export const Default: Story = {
render: (args) => <PasswordFormComponent {...args} />,
}

View File

@@ -8,7 +8,7 @@
gap: var(--Space-x05); gap: var(--Space-x05);
margin-top: var(--Space-x1); margin-top: var(--Space-x1);
font-size: var(--Body-Supporting-text-Size); font-size: var(--Body-Supporting-text-Size);
font-family: var(--Body-Supporting-text-Font-family, "Fira Sans"); font-family: var(--Body-Supporting-text-Font-family);
font-style: normal; font-style: normal;
font-weight: var(--Body-Supporting-text-Font-weight); font-weight: var(--Body-Supporting-text-Font-weight);
letter-spacing: var(--Body-Supporting-text-Letter-spacing); letter-spacing: var(--Body-Supporting-text-Letter-spacing);

View File

@@ -9,7 +9,7 @@
gap: var(--Space-x05); gap: var(--Space-x05);
margin-top: var(--Space-x1); margin-top: var(--Space-x1);
font-size: var(--Body-Supporting-text-Size); font-size: var(--Body-Supporting-text-Size);
font-family: var(--Body-Supporting-text-Font-family, "Fira Sans"); font-family: var(--Body-Supporting-text-Font-family);
font-style: normal; font-style: normal;
font-weight: var(--Body-Supporting-text-Font-weight); font-weight: var(--Body-Supporting-text-Font-weight);
letter-spacing: var(--Body-Supporting-text-Letter-spacing); letter-spacing: var(--Body-Supporting-text-Letter-spacing);

View File

@@ -1,179 +1,101 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite" import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { z } from "zod"
import { expect } from "storybook/test" import { expect } from "storybook/test"
import { Input } from "./Input" import { FormInput } from "../Form/FormInput"
import { TextField } from "react-aria-components"
import { MaterialIcon } from "../Icons/MaterialIcon" import { MaterialIcon } from "../Icons/MaterialIcon"
import type { MaterialIconProps } from "../Icons/MaterialIcon"
import { Typography } from "../Typography"
import { MaterialIconName } from "../Icons/MaterialIcon/generated" import { MaterialIconName } from "../Icons/MaterialIcon/generated"
const meta: Meta<typeof Input> = { interface FormInputStoryProps {
title: "Core Components/Input", label?: string
// @ts-expect-error Input does not support this, but wrapping <TextField> does labelPosition?: "floating" | "top"
component: ({ isInvalid, validationState, ...props }) => ( placeholder?: string
<TextField isInvalid={isInvalid} data-validation-state={validationState}> description?: string
<Input {...props} data-validation-state={validationState} /> descriptionIcon?: MaterialIconProps["icon"]
</TextField> required?: boolean
), disabled?: boolean
argTypes: { readOnly?: boolean
label: { showClearContentIcon?: boolean
control: "text",
description: "The label text displayed for the input field",
table: {
type: { summary: "string" },
},
},
labelPosition: {
control: "select",
options: ["floating", "top"],
description: "Position of the label relative to the input",
table: {
type: { summary: "'floating' | 'top'" },
defaultValue: { summary: "'floating'" },
},
},
placeholder: {
control: "text",
description: "Placeholder text shown when input is empty",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
},
},
required: {
control: "boolean",
description: "Whether the input is required",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
disabled: {
control: "boolean",
description: "Whether the input is disabled",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
showClearContentIcon: {
control: "boolean",
description: "Whether the clear content icon is shown",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
showLeftIcon: {
control: "boolean",
description: "Whether to show a left icon",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
showRightIcon: {
control: "boolean",
description: "Whether to show a right icon",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
leftIconName: {
control: "select",
options: [
"calendar_month",
"credit_card",
"email",
"info_circle",
"location_on",
"lock",
"phone",
"search",
"sell",
"visibility",
"visibility_off",
],
description: "Icon name for the left icon",
table: {
type: { summary: "string" },
defaultValue: { summary: "'person'" },
},
},
rightIconName: {
control: "select",
options: [
"calendar_month",
"credit_card",
"email",
"info_circle",
"location_on",
"lock",
"phone",
"search",
"sell",
"visibility",
"visibility_off",
],
description: "Icon name for the right icon",
table: {
type: { summary: "string" },
defaultValue: { summary: "'lock'" },
},
},
showWarning: {
control: "boolean",
description: "Whether to show warning validation state",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
}
export default meta
type Story = StoryObj<typeof Input>
export const Default: Story = {
args: {
label: "Label",
name: "foo",
required: false,
showLeftIcon: false,
showRightIcon: false,
leftIconName: "person",
rightIconName: "lock",
showWarning: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
render: (args) => {
// Extract custom Storybook args
const {
showLeftIcon,
showRightIcon,
leftIconName,
rightIconName,
showWarning,
...inputProps
} = args as typeof args & {
showLeftIcon?: boolean showLeftIcon?: boolean
showRightIcon?: boolean showRightIcon?: boolean
leftIconName?: string leftIconName?: string
rightIconName?: string rightIconName?: string
showWarning?: boolean showWarning?: boolean
errorMessage?: string
defaultValue?: string
autoComplete?: string
}
function FormInputComponent({
label = "Label",
labelPosition = "floating",
placeholder,
description,
descriptionIcon = "info",
required = false,
disabled = false,
readOnly = false,
showClearContentIcon = false,
showLeftIcon = false,
showRightIcon = false,
leftIconName = "person",
rightIconName = "lock",
showWarning = false,
errorMessage,
defaultValue = "",
autoComplete,
}: FormInputStoryProps) {
const schema = z.object({
input: errorMessage
? z.string().min(1, errorMessage)
: required
? z.string().min(1, "This field is required")
: z.string().optional(),
})
const methods = useForm<{ input: string }>({
resolver: zodResolver(schema),
defaultValues: {
input: defaultValue,
},
mode: "onChange",
})
useEffect(() => {
if (errorMessage) {
methods.setError("input", {
type: "manual",
message: errorMessage,
})
methods.trigger("input")
} else {
methods.clearErrors("input")
} }
}, [errorMessage, methods])
const validationState = showWarning ? "warning" : undefined const validationState = showWarning ? "warning" : undefined
return ( return (
<TextField data-validation-state={validationState}> <FormProvider {...methods}>
<Input <div style={{ padding: "1rem" }}>
{...inputProps} <FormInput
data-validation-state={validationState} name="input"
label={label}
labelPosition={labelPosition}
placeholder={placeholder}
description={description}
descriptionIcon={descriptionIcon}
disabled={disabled}
readOnly={readOnly}
required={required}
registerOptions={required ? { required: true } : undefined}
showClearContentIcon={showClearContentIcon}
leftIcon={ leftIcon={
showLeftIcon && leftIconName ? ( showLeftIcon && leftIconName ? (
<MaterialIcon icon={leftIconName as MaterialIconName} /> <MaterialIcon icon={leftIconName as MaterialIconName} />
@@ -184,9 +106,247 @@ export const Default: Story = {
<MaterialIcon icon={rightIconName as MaterialIconName} /> <MaterialIcon icon={rightIconName as MaterialIconName} />
) : undefined ) : undefined
} }
validationState={validationState}
autoComplete={autoComplete}
/> />
</TextField> </div>
</FormProvider>
) )
}
const meta: Meta<typeof FormInputComponent> = {
title: "Core Components/Input/TextInput",
component: FormInputComponent,
argTypes: {
label: {
control: "text",
name: "Label",
description: "Label text",
table: {
type: { summary: "string" },
order: 1,
},
},
labelPosition: {
control: "select",
name: "Label Position",
options: ["floating", "top"],
description: "Label position",
table: {
type: { summary: "'floating' | 'top'" },
defaultValue: { summary: "'floating'" },
order: 2,
},
},
placeholder: {
control: "text",
name: "Placeholder",
description: "Placeholder text",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
order: 3,
},
},
description: {
control: "text",
name: "Description",
description: "Helper text (hidden when error is shown)",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
order: 4,
},
},
descriptionIcon: {
control: "select",
name: "Description Icon",
options: ["info", "warning", "error"],
description: "Description icon",
table: {
type: { summary: "string" },
defaultValue: { summary: "'info'" },
order: 5,
},
},
required: {
control: "boolean",
name: "Required",
description: "Required field",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 6,
},
},
disabled: {
control: "boolean",
name: "Disabled",
description: "Disabled state",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 7,
},
},
readOnly: {
control: "boolean",
name: "Read Only",
description: "Read-only state",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 8,
},
},
showClearContentIcon: {
control: "boolean",
name: "Show Clear Icon",
description: "Show clear button",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 9,
},
},
showLeftIcon: {
control: "boolean",
name: "Show Left Icon",
description: "Show left icon",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 10,
},
},
leftIconName: {
control: "select",
name: "Left Icon",
options: [
"calendar_month",
"credit_card",
"mail",
"location_on",
"info",
"lock",
"phone",
"search",
"sell",
"visibility",
"visibility_off",
],
description: "Left icon name",
table: {
type: { summary: "string" },
defaultValue: { summary: "'person'" },
order: 11,
},
},
showRightIcon: {
control: "boolean",
name: "Show Right Icon",
description: "Show right icon",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 12,
},
},
rightIconName: {
control: "select",
name: "Right Icon",
options: [
"calendar_month",
"credit_card",
"mail",
"location_on",
"info",
"lock",
"phone",
"search",
"sell",
"visibility",
"visibility_off",
],
description: "Right icon name",
table: {
type: { summary: "string" },
defaultValue: { summary: "'lock'" },
order: 13,
},
},
showWarning: {
control: "boolean",
name: "Show Warning",
description: "Warning state",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 14,
},
},
errorMessage: {
control: "text",
name: "Error Message",
description: "Error message",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
order: 15,
},
},
autoComplete: {
control: "select",
name: "Autocomplete",
description: "HTML autocomplete attribute value",
options: [
undefined,
"name",
"given-name",
"family-name",
"mail",
"username",
"tel",
"tel-national",
"organization",
"address-line1",
"address-line2",
"city",
"postal-code",
"country",
"off",
],
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
order: 16,
},
},
},
}
export default meta
type Story = StoryObj<typeof FormInputComponent>
export const Default: Story = {
args: {
label: "Label",
labelPosition: "floating",
placeholder: undefined,
description: undefined,
descriptionIcon: "info",
required: false,
disabled: false,
readOnly: false,
showClearContentIcon: false,
showLeftIcon: false,
showRightIcon: false,
leftIconName: "person",
rightIconName: "lock",
showWarning: false,
errorMessage: undefined,
autoComplete: undefined,
}, },
play: async ({ canvas, userEvent }) => { play: async ({ canvas, userEvent }) => {
const textbox = canvas.getByRole("textbox") const textbox = canvas.getByRole("textbox")
@@ -201,3 +361,314 @@ export const Default: Story = {
expect(textbox).toHaveValue("") expect(textbox).toHaveValue("")
}, },
} }
// ============================================================================
// Input Variations Showcase
// ============================================================================
const showcaseSchema = z.object({
default: z.string().optional(),
placeholder: z.string().optional(),
filled: z.string().optional(),
required: z.string().min(1, "This field is required"),
disabled: z.string().optional(),
disabledFilled: z.string().optional(),
warningState: z.string().optional(),
warningFilled: z.string().optional(),
emailIcon: z.string().optional(),
searchIcon: z.string().optional(),
locked: z.string().optional(),
bothIcons: z.string().optional(),
emailIconTop: z.string().optional(),
searchIconTop: z.string().optional(),
lockedTop: z.string().optional(),
bothIconsTop: z.string().optional(),
emailClear: z.string().optional(),
searchClear: z.string().optional(),
clearLeftRight: z.string().optional(),
emptyClear: z.string().optional(),
error: z.string().min(10, "Must be at least 10 characters"),
errorFilled: z.string().email("Invalid email"),
warning: z.string().optional(),
warningFilledValidation: z.string().optional(),
text: z.string().optional(),
emailType: z.string().optional(),
telType: z.string().optional(),
number: z.string().optional(),
passwordType: z.string().optional(),
urlType: z.string().optional(),
combined1: z.string().optional(),
combined2: z.string().email("Invalid email"),
combined3: z.string().optional(),
})
type ShowcaseFormData = z.infer<typeof showcaseSchema>
function InputShowcase() {
const methods = useForm<ShowcaseFormData>({
resolver: zodResolver(showcaseSchema),
defaultValues: {
filled: "Sample text",
disabledFilled: "Cannot edit",
warningFilled: "Needs attention",
emailClear: "user@example.com",
searchClear: "",
clearLeftRight: "user@example.com",
error: "Short",
errorFilled: "Invalid input",
warningFilledValidation: "Needs attention",
combined1: "user@example.com",
combined2: "Invalid email",
},
mode: "onChange",
})
// Trigger validation for error examples on mount
useEffect(() => {
methods.trigger(["error", "errorFilled", "combined2"])
}, [methods])
return (
<FormProvider {...methods}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "3rem",
maxWidth: "800px",
padding: "2rem",
}}
>
{/* Basic States */}
<section>
<Typography variant="Title/sm">
<h2>Basic States</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput name="default" label="Default" />
<FormInput
name="placeholder"
label="Placeholder"
placeholder="Enter text here..."
/>
<FormInput
name="default2"
label="Default"
description="This is a default input with a description"
/>
<FormInput
name="default3"
label="Default"
placeholder="Enter text here..."
description="This is a default input with a description"
/>
<FormInput name="filled" label="Filled" />
<FormInput
name="required"
label="Required"
registerOptions={{ required: true }}
hideError
/>
<FormInput name="disabled" label="Disabled" disabled />
<FormInput name="disabledFilled" label="Disabled Filled" disabled />
<FormInput
name="warningState"
label="Warning State"
validationState="warning"
/>
<FormInput
name="warningFilled"
label="Warning with Value"
validationState="warning"
/>
</div>
</section>
{/* With Icons */}
<section>
<Typography variant="Title/sm">
<h2>With Icons</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
name="emailIcon"
label="Email"
type="email"
leftIcon={<MaterialIcon icon="mail" />}
/>
<FormInput
name="searchIcon"
label="Search"
leftIcon={<MaterialIcon icon="search" />}
/>
<FormInput
name="locked"
label="Locked icon"
rightIcon={<MaterialIcon icon="lock" />}
/>
<FormInput
name="bothIcons"
label="With Both Icons"
leftIcon={<MaterialIcon icon="person" />}
rightIcon={<MaterialIcon icon="check_circle" />}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
name="emailIconTop"
label="Email"
type="email"
leftIcon={<MaterialIcon icon="mail" />}
labelPosition="top"
/>
<FormInput
name="searchIconTop"
label="Search"
leftIcon={<MaterialIcon icon="search" />}
labelPosition="top"
/>
<FormInput
name="lockedTop"
label="Locked icon"
rightIcon={<MaterialIcon icon="lock" />}
labelPosition="top"
/>
<FormInput
name="bothIconsTop"
label="With Both Icons"
leftIcon={<MaterialIcon icon="person" />}
rightIcon={<MaterialIcon icon="check_circle" />}
labelPosition="top"
/>
</div>
</section>
{/* Clear Button */}
<section>
<Typography variant="Title/sm">
<h2>Clear Button</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput name="emptyClear" label="Empty" showClearContentIcon />
<FormInput
name="emailClear"
label="Email with Clear"
type="email"
showClearContentIcon
/>
<FormInput
name="clearLeftRight"
label="Clear with Left Icon"
leftIcon={<MaterialIcon icon="mail" />}
showClearContentIcon
/>
<FormInput
name="clearRightIcon"
label="Clear with Right Icon"
rightIcon={<MaterialIcon icon="lock" />}
showClearContentIcon
/>
<FormInput
name="clearBothIcons"
label="Clear with Both Icons"
leftIcon={<MaterialIcon icon="mail" />}
rightIcon={<MaterialIcon icon="lock" />}
showClearContentIcon
/>
</div>
</section>
{/* Validation States */}
<section>
<Typography variant="Title/sm">
<h2>Validation States</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput
name="error"
label="Error State"
registerOptions={{ minLength: 10 }}
/>
<FormInput
name="errorFilled"
label="Error with Value"
registerOptions={{ required: true }}
/>
<FormInput
name="warning"
label="Warning State"
validationState="warning"
/>
<FormInput
name="warningFilledValidation"
label="Warning with Value"
validationState="warning"
/>
</div>
</section>
{/* Input Types */}
<section>
<Typography variant="Title/sm">
<h2>Input Types</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormInput name="text" label="Text" type="text" />
<FormInput name="emailType" label="Email" type="email" />
<FormInput name="number" label="Number" type="number" />
</div>
</section>
</div>
</FormProvider>
)
}
export const AllVariations: StoryObj<typeof InputShowcase> = {
render: () => <InputShowcase />,
parameters: {
layout: "fullscreen",
},
}

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite" import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { fn } from "storybook/test"
import { useEffect } from "react" import { useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
@@ -7,8 +6,8 @@ import { z } from "zod"
import type { IntlShape } from "react-intl" import type { IntlShape } from "react-intl"
import { PasswordInput } from "./index" import { PasswordInput } from "./index"
import { Button } from "../Button"
import { Typography } from "../Typography" import { Typography } from "../Typography"
import type { MaterialIconProps } from "../Icons/MaterialIcon"
import { passwordValidator } from "@scandic-hotels/common/utils/zod/passwordValidator" import { passwordValidator } from "@scandic-hotels/common/utils/zod/passwordValidator"
// Simple error formatter for Storybook // Simple error formatter for Storybook
@@ -17,131 +16,230 @@ const defaultErrorFormatter = (
errorMessage?: string errorMessage?: string
): string => errorMessage ?? "" ): string => errorMessage ?? ""
// ============================================================================ interface PasswordInputStoryProps {
// Password Form with New Password Validation label?: string
// ============================================================================ placeholder?: string
disabled?: boolean
const passwordFormSchema = z visibilityToggleable?: boolean
.object({ isNewPassword?: boolean
currentPassword: z.string().optional(), required?: boolean
newPassword: z.literal("").optional().or(passwordValidator()), errorMessage?: string
confirmPassword: z.string().optional(), defaultValue?: string
}) description?: string
.superRefine((data, ctx) => { descriptionIcon?: MaterialIconProps["icon"]
if (data.newPassword && data.newPassword !== data.confirmPassword) { autoComplete?: string
ctx.addIssue({
code: "custom",
message: "Passwords do not match",
path: ["confirmPassword"],
})
}
})
type PasswordFormData = z.infer<typeof passwordFormSchema>
interface PasswordInputProps {
onSubmit?: (data: PasswordFormData) => void
showErrors?: boolean
defaultNewPassword?: string
} }
function PasswordInputComponent({ function PasswordInputComponent({
onSubmit, label,
showErrors = false, placeholder,
defaultNewPassword = "", disabled = false,
}: PasswordInputProps) { visibilityToggleable = true,
const methods = useForm<PasswordFormData>({ isNewPassword = false,
resolver: zodResolver(passwordFormSchema), required = false,
errorMessage,
defaultValue = "",
description,
descriptionIcon = "info",
autoComplete,
}: PasswordInputStoryProps) {
const schema = z.object({
password: errorMessage
? isNewPassword
? z.literal("").optional().or(passwordValidator())
: z.string().min(1, errorMessage)
: required
? isNewPassword
? passwordValidator()
: z.string().min(1, "This field is required")
: isNewPassword
? z.literal("").optional().or(passwordValidator())
: z.string().optional(),
})
const methods = useForm<{ password: string }>({
resolver: zodResolver(schema),
defaultValues: { defaultValues: {
currentPassword: "", password: defaultValue,
newPassword: defaultNewPassword,
confirmPassword: "",
}, },
mode: "all", mode: isNewPassword ? "all" : "onChange",
criteriaMode: "all", criteriaMode: isNewPassword ? "all" : undefined,
reValidateMode: "onChange", reValidateMode: isNewPassword ? "onChange" : undefined,
}) })
// Trigger validation on mount if showErrors is true
useEffect(() => { useEffect(() => {
if (showErrors) { if (errorMessage && !isNewPassword) {
methods.trigger() methods.setError("password", {
} type: "manual",
}, [showErrors, methods]) message: errorMessage,
const handleSubmit = methods.handleSubmit((data) => {
onSubmit?.(data)
}) })
methods.trigger("password")
} else if (isNewPassword && defaultValue) {
methods.trigger("password")
} else {
methods.clearErrors("password")
}
}, [errorMessage, methods, isNewPassword, defaultValue])
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form <div style={{ padding: "1rem" }}>
onSubmit={handleSubmit}
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
maxWidth: "500px",
}}
>
<Typography variant="Title/md">
<h2>Change Password</h2>
</Typography>
<PasswordInput <PasswordInput
name="currentPassword" name="password"
label="Current password" label={label}
placeholder={placeholder}
disabled={disabled}
visibilityToggleable={visibilityToggleable}
isNewPassword={isNewPassword}
registerOptions={required ? { required: true } : undefined}
errorFormatter={defaultErrorFormatter} errorFormatter={defaultErrorFormatter}
description={description}
descriptionIcon={descriptionIcon}
autoComplete={autoComplete}
/> />
</div>
<PasswordInput
name="newPassword"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
<PasswordInput
name="confirmPassword"
label="Confirm new password"
errorFormatter={defaultErrorFormatter}
/>
<Button type="submit" variant="Primary" size="lg">
Update password
</Button>
</form>
</FormProvider> </FormProvider>
) )
} }
const passwordFormMeta: Meta<typeof PasswordInputComponent> = { const meta: Meta<typeof PasswordInputComponent> = {
title: "Core Components/PasswordInput", title: "Core Components/Input/PasswordInput",
component: PasswordInputComponent, component: PasswordInputComponent,
parameters: {
layout: "padded",
},
argTypes: { argTypes: {
showErrors: { label: {
control: "boolean",
description: "Show validation errors on mount",
},
defaultNewPassword: {
control: "text", control: "text",
description: "Default value for new password field", name: "Label",
description: "Label text",
table: {
type: { summary: "string" },
order: 1,
},
},
placeholder: {
control: "text",
name: "Placeholder",
description: "Placeholder text",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
order: 2,
},
},
disabled: {
control: "boolean",
name: "Disabled",
description: "Disabled state",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 3,
},
},
visibilityToggleable: {
control: "boolean",
name: "Visibility Toggleable",
description: "Show/hide password toggle button",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "true" },
order: 4,
},
},
isNewPassword: {
control: "boolean",
name: "Is New Password",
description: "Enable new password validation",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 5,
},
},
required: {
control: "boolean",
name: "Required",
description: "Required field",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 6,
},
},
errorMessage: {
control: "text",
name: "Error Message",
description: "Error message (only for non-new-password fields)",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
order: 7,
},
},
defaultValue: {
control: "text",
name: "Default Value",
description: "Default password value",
table: {
type: { summary: "string" },
defaultValue: { summary: "''" },
order: 8,
},
},
description: {
control: "text",
name: "Description",
description: "Helper text (hidden when error is shown)",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
order: 9,
},
},
descriptionIcon: {
control: "select",
name: "Description Icon",
options: ["info", "warning", "error"],
description: "Description icon",
table: {
type: { summary: "string" },
defaultValue: { summary: "'info'" },
order: 10,
},
},
autoComplete: {
control: "select",
name: "Autocomplete",
description:
"HTML autocomplete attribute value. If not provided, automatically sets to 'new-password' when isNewPassword is true, or 'current-password' when isNewPassword is false.",
options: [undefined, "current-password", "new-password", "off"],
table: {
type: { summary: "string" },
defaultValue: {
summary: "undefined (auto-set based on isNewPassword)",
},
order: 11,
},
}, },
}, },
} }
export default passwordFormMeta export default meta
type PasswordFormStory = StoryObj<typeof PasswordInputComponent> type Story = StoryObj<typeof PasswordInputComponent>
export const Default: PasswordFormStory = { export const Default: Story = {
render: (args) => <PasswordInputComponent {...args} />,
args: { args: {
onSubmit: fn(), label: undefined,
showErrors: false, placeholder: undefined,
defaultNewPassword: "", disabled: false,
visibilityToggleable: true,
isNewPassword: false,
required: false,
errorMessage: undefined,
defaultValue: "",
description: undefined,
descriptionIcon: "info",
autoComplete: undefined,
}, },
} }
@@ -191,8 +289,8 @@ function PasswordValidationShowcase() {
}} }}
> >
<section> <section>
<Typography variant="Title/md"> <Typography variant="Title/sm">
<h2>New Password Validation States</h2> <h2>Password Validation States</h2>
</Typography> </Typography>
<div <div
style={{ style={{
@@ -213,28 +311,6 @@ function PasswordValidationShowcase() {
/> />
</div> </div>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>Weak Password (too short)</p>
</Typography>
<PasswordInput
name="weak"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
</div>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>Partial Password (missing requirements)</p>
</Typography>
<PasswordInput
name="partial"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
</div>
<div> <div>
<Typography variant="Body/Paragraph/mdRegular"> <Typography variant="Body/Paragraph/mdRegular">
<p>Valid Password</p> <p>Valid Password</p>
@@ -246,6 +322,17 @@ function PasswordValidationShowcase() {
/> />
</div> </div>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>Too Short Password</p>
</Typography>
<PasswordInput
name="weak"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
</div>
<div> <div>
<Typography variant="Body/Paragraph/mdRegular"> <Typography variant="Body/Paragraph/mdRegular">
<p>Too Long Password</p> <p>Too Long Password</p>

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { TextField } from "react-aria-components" import { Text, TextField } from "react-aria-components"
import { import {
Controller, Controller,
type RegisterOptions, type RegisterOptions,
@@ -9,7 +9,7 @@ import {
} from "react-hook-form" } from "react-hook-form"
import { useIntl, type IntlShape } from "react-intl" import { useIntl, type IntlShape } from "react-intl"
import { MaterialIcon } from "../Icons/MaterialIcon" import { MaterialIcon, type MaterialIconProps } from "../Icons/MaterialIcon"
import { Input } from "../Input" import { Input } from "../Input"
import { Typography } from "../Typography" import { Typography } from "../Typography"
@@ -28,6 +28,10 @@ interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement>
visibilityToggleable?: boolean visibilityToggleable?: boolean
isNewPassword?: boolean isNewPassword?: boolean
errorFormatter?: (intl: IntlShape, errorMessage?: string) => string errorFormatter?: (intl: IntlShape, errorMessage?: string) => string
/** Helper text displayed below the input (hidden when there's an error) */
description?: string
descriptionIcon?: MaterialIconProps["icon"]
autoComplete?: string
} }
export const PasswordInput = ({ export const PasswordInput = ({
@@ -41,6 +45,9 @@ export const PasswordInput = ({
isNewPassword = false, isNewPassword = false,
className = "", className = "",
errorFormatter, errorFormatter,
description = "",
descriptionIcon = "info",
autoComplete,
}: PasswordInputProps) => { }: PasswordInputProps) => {
const { control } = useFormContext() const { control } = useFormContext()
const intl = useIntl() const intl = useIntl()
@@ -48,6 +55,10 @@ export const PasswordInput = ({
const formatErrorMessage = errorFormatter ?? defaultErrorFormatter const formatErrorMessage = errorFormatter ?? defaultErrorFormatter
// Automatically set autocomplete based on isNewPassword if not explicitly provided
const autocompleteValue =
autoComplete ?? (isNewPassword ? "new-password" : "current-password")
return ( return (
<Controller <Controller
disabled={disabled} disabled={disabled}
@@ -75,6 +86,7 @@ export const PasswordInput = ({
const hasError = !!fieldState.error const hasError = !!fieldState.error
const showRequirements = isNewPassword && !!field.value const showRequirements = isNewPassword && !!field.value
const showDescription = description && !fieldState.error
return ( return (
<TextField <TextField
@@ -115,6 +127,7 @@ export const PasswordInput = ({
? "text" ? "text"
: "password" : "password"
} }
autoComplete={autocompleteValue}
/> />
{visibilityToggleable ? ( {visibilityToggleable ? (
<button <button
@@ -143,6 +156,13 @@ export const PasswordInput = ({
) : null} ) : null}
</div> </div>
{showDescription ? (
<Text className={styles.description} slot="description">
<MaterialIcon icon={descriptionIcon} size={20} />
{description}
</Text>
) : null}
{showRequirements ? ( {showRequirements ? (
<NewPasswordValidation <NewPasswordValidation
value={field.value} value={field.value}

View File

@@ -4,6 +4,18 @@
gap: var(--Space-x05); gap: var(--Space-x05);
} }
.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);
font-style: normal;
font-weight: var(--Body-Supporting-text-Font-weight);
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
}
.error { .error {
align-items: center; align-items: center;
color: var(--Text-Interactive-Error); color: var(--Text-Interactive-Error);

View File

@@ -6,6 +6,7 @@ import { z } from "zod"
import { FormTextArea } from "../Form/FormTextArea" import { FormTextArea } from "../Form/FormTextArea"
import type { MaterialIconProps } from "../Icons/MaterialIcon" import type { MaterialIconProps } from "../Icons/MaterialIcon"
import { Typography } from "../Typography"
interface FormTextAreaStoryProps { interface FormTextAreaStoryProps {
label?: string label?: string
@@ -90,87 +91,107 @@ function FormTextAreaComponent({
} }
const meta: Meta<typeof FormTextAreaComponent> = { const meta: Meta<typeof FormTextAreaComponent> = {
title: "Core Components/TextArea", title: "Core Components/Input/TextArea",
component: FormTextAreaComponent, component: FormTextAreaComponent,
argTypes: { argTypes: {
label: { label: {
control: "text", control: "text",
description: "The label text displayed above the textarea field", name: "Label",
description: "Label text",
table: { table: {
type: { summary: "string" }, type: { summary: "string" },
order: 1,
}, },
}, },
placeholder: { placeholder: {
control: "text", control: "text",
description: "Placeholder text shown when textarea is empty", name: "Placeholder",
description: "Placeholder text",
table: { table: {
type: { summary: "string" }, type: { summary: "string" },
defaultValue: { summary: "undefined" }, defaultValue: { summary: "undefined" },
}, order: 2,
},
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: { showDescription: {
control: "boolean", control: "boolean",
description: "Whether to show the description/supporting text", name: "Show Description",
description: "Show description",
table: { table: {
type: { summary: "boolean" }, type: { summary: "boolean" },
defaultValue: { summary: "true" }, defaultValue: { summary: "true" },
order: 3,
}, },
}, },
defaultValue: { description: {
control: "text", control: "text",
description: "Default value when filled is true", name: "Description",
description: "Helper text (hidden when error is shown)",
table: { table: {
type: { summary: "string" }, type: { summary: "string" },
defaultValue: { summary: "" }, defaultValue: { summary: "undefined" },
order: 4,
},
},
descriptionIcon: {
control: "select",
name: "Description Icon",
options: ["info", "warning", "error"],
description: "Description icon",
table: {
type: { summary: "string" },
defaultValue: { summary: "'info'" },
order: 5,
},
},
required: {
control: "boolean",
name: "Required",
description: "Required field",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 6,
},
},
disabled: {
control: "boolean",
name: "Disabled",
description: "Disabled state",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 7,
},
},
readOnly: {
control: "boolean",
name: "Read Only",
description: "Read-only state",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
order: 8,
},
},
showLabel: {
control: "boolean",
name: "Show Label",
description: "Show label",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "true" },
order: 9,
}, },
}, },
errorMessage: { errorMessage: {
control: "text", control: "text",
description: "Error message to display (triggers error state)", name: "Error Message",
description: "Error message",
table: { table: {
type: { summary: "string" }, type: { summary: "string" },
defaultValue: { summary: "undefined" }, defaultValue: { summary: "undefined" },
order: 10,
}, },
}, },
}, },
@@ -184,13 +205,167 @@ export const Default: Story = {
args: { args: {
label: "Label", label: "Label",
placeholder: "Placeholder", placeholder: "Placeholder",
description: "Supporting text", required: false,
disabled: false, disabled: false,
readOnly: false, readOnly: false,
required: false,
showLabel: true, showLabel: true,
showDescription: true, showDescription: true,
defaultValue: "",
errorMessage: undefined, errorMessage: undefined,
}, },
} }
// ============================================================================
// TextArea Variations Showcase
// ============================================================================
const showcaseSchema = z.object({
default: z.string().optional(),
placeholder: z.string().optional(),
withDescription: z.string().optional(),
withDescriptionPlaceholder: z.string().optional(),
filled: z.string().optional(),
required: z.string().min(1, "This field is required"),
disabled: z.string().optional(),
disabledFilled: z.string().optional(),
error: z.string().min(10, "Must be at least 10 characters"),
errorFilled: z.string().min(10, "Invalid content"),
descriptionInfo: z.string().optional(),
descriptionWarning: z.string().optional(),
})
type ShowcaseFormData = z.infer<typeof showcaseSchema>
function TextAreaShowcase() {
const methods = useForm<ShowcaseFormData>({
resolver: zodResolver(showcaseSchema),
defaultValues: {
filled:
"This is a sample text that fills the textarea with some content.",
disabledFilled: "This textarea is disabled and cannot be edited.",
error: "Short",
errorFilled: "Invalid content",
},
mode: "onChange",
})
// Trigger validation for error examples on mount
useEffect(() => {
methods.trigger(["error", "errorFilled"])
}, [methods])
return (
<FormProvider {...methods}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "3rem",
maxWidth: "800px",
padding: "2rem",
}}
>
{/* Basic States */}
<section>
<Typography variant="Title/sm">
<h2>Basic States</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormTextArea name="default" label="Default" />
<FormTextArea
name="placeholder"
label="Placeholder"
placeholder="Enter your message here..."
/>
<FormTextArea
name="withDescription"
label="With Description"
description="This is a default textarea with a description"
/>
<FormTextArea
name="withDescriptionPlaceholder"
label="With Description"
placeholder="Enter your message here..."
description="This is a default textarea with a description"
/>
<FormTextArea name="filled" label="Filled" />
<FormTextArea
name="required"
label="Required"
registerOptions={{ required: true }}
hideError
/>
<FormTextArea name="disabled" label="Disabled" disabled />
<FormTextArea
name="disabledFilled"
label="Disabled Filled"
disabled
/>
</div>
</section>
{/* Supporting Text Variations */}
<section>
<Typography variant="Title/sm">
<h2>Supporting Text</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormTextArea
name="descriptionInfo"
label="Info Description"
description="This is an informational message"
descriptionIcon="info"
/>
<FormTextArea
name="descriptionWarning"
label="Warning Description"
description="This is a warning message"
descriptionIcon="warning"
/>
</div>
</section>
{/* Validation States */}
<section>
<Typography variant="Title/sm">
<h2>Validation States</h2>
</Typography>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "1.5rem",
marginTop: "1rem",
}}
>
<FormTextArea
name="error"
label="Error State"
registerOptions={{ minLength: 10 }}
/>
</div>
</section>
</div>
</FormProvider>
)
}
export const AllVariations: StoryObj<typeof TextAreaShowcase> = {
render: () => <TextAreaShowcase />,
parameters: {
layout: "fullscreen",
},
}