diff --git a/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx b/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx index b5037f5ec..465e1b2c6 100644 --- a/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx +++ b/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx @@ -6,7 +6,6 @@ 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" @@ -376,333 +375,3 @@ export const WithErrors: SignupStory = { ...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 - -function InputShowcase() { - const methods = useForm({ - 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 ( - -
- -

FormInput Component Showcase

-
- - {/* Basic States */} -
- -

Basic States

-
-
- - - - - - - - - - -
-
- - {/* With Icons */} -
- -

With Icons

-
-
- } - /> - } - /> - } - /> - } - rightIcon={} - /> -
-
- } - labelPosition="top" - /> - } - labelPosition="top" - /> - } - labelPosition="top" - /> - } - rightIcon={} - labelPosition="top" - /> -
-
- - {/* Clear Button */} -
- -

Clear Button

-
-
- - } - showClearContentIcon - /> - } - showClearContentIcon - /> - } - showClearContentIcon - /> - } - rightIcon={} - showClearContentIcon - /> - -
-
- - {/* Validation States */} -
- -

Validation States

-
-
- - - - -
-
- - {/* Input Types */} -
- -

Input Types

-
-
- - - -
-
-
-
- ) -} - -const showcaseMeta: Meta = { - title: "Compositions/Form/InputShowcase", - component: InputShowcase, - parameters: { - layout: "fullscreen", - }, -} - -type ShowcaseStory = StoryObj - -export const AllVariations: ShowcaseStory = { - render: () => , - parameters: { - ...showcaseMeta.parameters, - }, -} diff --git a/packages/design-system/lib/components/Form/Compositions/PasswordForm.stories.tsx b/packages/design-system/lib/components/Form/Compositions/PasswordForm.stories.tsx new file mode 100644 index 000000000..a68fb42f7 --- /dev/null +++ b/packages/design-system/lib/components/Form/Compositions/PasswordForm.stories.tsx @@ -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 + +interface PasswordFormProps { + onSubmit?: (data: PasswordFormData) => void +} + +function PasswordFormComponent({ onSubmit }: PasswordFormProps) { + const methods = useForm({ + resolver: zodResolver(passwordFormSchema), + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + mode: "all", + criteriaMode: "all", + reValidateMode: "onChange", + }) + + const handleSubmit = methods.handleSubmit((data) => { + onSubmit?.(data) + }) + + return ( + +
+ +

Change Password

+
+ + + + + + + + + +
+ ) +} + +const meta: Meta = { + title: "Compositions/Form/PasswordForm", + component: PasswordFormComponent, + parameters: { + layout: "padded", + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: (args) => , +} diff --git a/packages/design-system/lib/components/Form/FormInput/input.module.css b/packages/design-system/lib/components/Form/FormInput/input.module.css index 5a41eaa1d..a62be3140 100644 --- a/packages/design-system/lib/components/Form/FormInput/input.module.css +++ b/packages/design-system/lib/components/Form/FormInput/input.module.css @@ -8,7 +8,7 @@ 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-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); diff --git a/packages/design-system/lib/components/Form/FormTextArea/textarea.module.css b/packages/design-system/lib/components/Form/FormTextArea/textarea.module.css index 53c0638cb..6bb0bc647 100644 --- a/packages/design-system/lib/components/Form/FormTextArea/textarea.module.css +++ b/packages/design-system/lib/components/Form/FormTextArea/textarea.module.css @@ -9,7 +9,7 @@ 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-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); diff --git a/packages/design-system/lib/components/Input/Input.stories.tsx b/packages/design-system/lib/components/Input/Input.stories.tsx index 4146da0ba..0b520060e 100644 --- a/packages/design-system/lib/components/Input/Input.stories.tsx +++ b/packages/design-system/lib/components/Input/Input.stories.tsx @@ -1,179 +1,101 @@ 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 { Input } from "./Input" -import { TextField } from "react-aria-components" +import { FormInput } from "../Form/FormInput" import { MaterialIcon } from "../Icons/MaterialIcon" +import type { MaterialIconProps } from "../Icons/MaterialIcon" +import { Typography } from "../Typography" import { MaterialIconName } from "../Icons/MaterialIcon/generated" -const meta: Meta = { - title: "Core Components/Input", - // @ts-expect-error Input does not support this, but wrapping does - component: ({ isInvalid, validationState, ...props }) => ( - - - - ), - argTypes: { - label: { - 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, +interface FormInputStoryProps { + label?: string + labelPosition?: "floating" | "top" + placeholder?: string + description?: string + descriptionIcon?: MaterialIconProps["icon"] + required?: boolean + disabled?: boolean + readOnly?: boolean + showClearContentIcon?: boolean + showLeftIcon?: boolean + showRightIcon?: boolean + leftIconName?: string + rightIconName?: string + showWarning?: boolean + errorMessage?: string + defaultValue?: string + autoComplete?: string } -export default meta +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(), + }) -type Story = StoryObj + const methods = useForm<{ input: string }>({ + resolver: zodResolver(schema), + defaultValues: { + input: defaultValue, + }, + mode: "onChange", + }) -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 - showRightIcon?: boolean - leftIconName?: string - rightIconName?: string - showWarning?: boolean + 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 ( - - +
+ @@ -184,9 +106,247 @@ export const Default: Story = { ) : undefined } + validationState={validationState} + autoComplete={autoComplete} /> - - ) +
+ + ) +} + +const meta: Meta = { + 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 + +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 }) => { const textbox = canvas.getByRole("textbox") @@ -201,3 +361,314 @@ export const Default: Story = { 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 + +function InputShowcase() { + const methods = useForm({ + 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 ( + +
+ {/* Basic States */} +
+ +

Basic States

+
+
+ + + + + + + + + + +
+
+ + {/* With Icons */} +
+ +

With Icons

+
+
+ } + /> + } + /> + } + /> + } + rightIcon={} + /> +
+
+ } + labelPosition="top" + /> + } + labelPosition="top" + /> + } + labelPosition="top" + /> + } + rightIcon={} + labelPosition="top" + /> +
+
+ + {/* Clear Button */} +
+ +

Clear Button

+
+
+ + + + } + showClearContentIcon + /> + } + showClearContentIcon + /> + } + rightIcon={} + showClearContentIcon + /> +
+
+ + {/* Validation States */} +
+ +

Validation States

+
+
+ + + + +
+
+ + {/* Input Types */} +
+ +

Input Types

+
+
+ + + +
+
+
+
+ ) +} + +export const AllVariations: StoryObj = { + render: () => , + parameters: { + layout: "fullscreen", + }, +} diff --git a/packages/design-system/lib/components/PasswordInput/PasswordInput.stories.tsx b/packages/design-system/lib/components/PasswordInput/PasswordInput.stories.tsx index 15c34e9a2..d3172418a 100644 --- a/packages/design-system/lib/components/PasswordInput/PasswordInput.stories.tsx +++ b/packages/design-system/lib/components/PasswordInput/PasswordInput.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite" -import { fn } from "storybook/test" import { useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" @@ -7,8 +6,8 @@ import { z } from "zod" import type { IntlShape } from "react-intl" import { PasswordInput } from "./index" -import { Button } from "../Button" import { Typography } from "../Typography" +import type { MaterialIconProps } from "../Icons/MaterialIcon" import { passwordValidator } from "@scandic-hotels/common/utils/zod/passwordValidator" // Simple error formatter for Storybook @@ -17,131 +16,230 @@ const defaultErrorFormatter = ( errorMessage?: string ): string => errorMessage ?? "" -// ============================================================================ -// Password Form with New Password Validation -// ============================================================================ - -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 - -interface PasswordInputProps { - onSubmit?: (data: PasswordFormData) => void - showErrors?: boolean - defaultNewPassword?: string +interface PasswordInputStoryProps { + label?: string + placeholder?: string + disabled?: boolean + visibilityToggleable?: boolean + isNewPassword?: boolean + required?: boolean + errorMessage?: string + defaultValue?: string + description?: string + descriptionIcon?: MaterialIconProps["icon"] + autoComplete?: string } function PasswordInputComponent({ - onSubmit, - showErrors = false, - defaultNewPassword = "", -}: PasswordInputProps) { - const methods = useForm({ - resolver: zodResolver(passwordFormSchema), + label, + placeholder, + disabled = false, + visibilityToggleable = true, + isNewPassword = false, + 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: { - currentPassword: "", - newPassword: defaultNewPassword, - confirmPassword: "", + password: defaultValue, }, - mode: "all", - criteriaMode: "all", - reValidateMode: "onChange", + mode: isNewPassword ? "all" : "onChange", + criteriaMode: isNewPassword ? "all" : undefined, + reValidateMode: isNewPassword ? "onChange" : undefined, }) - // Trigger validation on mount if showErrors is true useEffect(() => { - if (showErrors) { - methods.trigger() + if (errorMessage && !isNewPassword) { + methods.setError("password", { + type: "manual", + message: errorMessage, + }) + methods.trigger("password") + } else if (isNewPassword && defaultValue) { + methods.trigger("password") + } else { + methods.clearErrors("password") } - }, [showErrors, methods]) - - const handleSubmit = methods.handleSubmit((data) => { - onSubmit?.(data) - }) + }, [errorMessage, methods, isNewPassword, defaultValue]) return ( -
- -

Change Password

-
- +
- - - - - - - +
) } -const passwordFormMeta: Meta = { - title: "Core Components/PasswordInput", +const meta: Meta = { + title: "Core Components/Input/PasswordInput", component: PasswordInputComponent, - parameters: { - layout: "padded", - }, argTypes: { - showErrors: { - control: "boolean", - description: "Show validation errors on mount", - }, - defaultNewPassword: { + label: { 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 +type Story = StoryObj -export const Default: PasswordFormStory = { - render: (args) => , +export const Default: Story = { args: { - onSubmit: fn(), - showErrors: false, - defaultNewPassword: "", + label: undefined, + placeholder: undefined, + disabled: false, + visibilityToggleable: true, + isNewPassword: false, + required: false, + errorMessage: undefined, + defaultValue: "", + description: undefined, + descriptionIcon: "info", + autoComplete: undefined, }, } @@ -191,8 +289,8 @@ function PasswordValidationShowcase() { }} >
- -

New Password Validation States

+ +

Password Validation States

-
- -

Weak Password (too short)

-
- -
- -
- -

Partial Password (missing requirements)

-
- -
-

Valid Password

@@ -246,6 +322,17 @@ function PasswordValidationShowcase() { />
+
+ +

Too Short Password

+
+ +
+

Too Long Password

diff --git a/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx b/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx index 0477e056b..46ec967de 100644 --- a/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx +++ b/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { TextField } from "react-aria-components" +import { Text, TextField } from "react-aria-components" import { Controller, type RegisterOptions, @@ -9,7 +9,7 @@ import { } from "react-hook-form" import { useIntl, type IntlShape } from "react-intl" -import { MaterialIcon } from "../Icons/MaterialIcon" +import { MaterialIcon, type MaterialIconProps } from "../Icons/MaterialIcon" import { Input } from "../Input" import { Typography } from "../Typography" @@ -28,6 +28,10 @@ interface PasswordInputProps extends React.InputHTMLAttributes visibilityToggleable?: boolean isNewPassword?: boolean 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 = ({ @@ -41,6 +45,9 @@ export const PasswordInput = ({ isNewPassword = false, className = "", errorFormatter, + description = "", + descriptionIcon = "info", + autoComplete, }: PasswordInputProps) => { const { control } = useFormContext() const intl = useIntl() @@ -48,6 +55,10 @@ export const PasswordInput = ({ const formatErrorMessage = errorFormatter ?? defaultErrorFormatter + // Automatically set autocomplete based on isNewPassword if not explicitly provided + const autocompleteValue = + autoComplete ?? (isNewPassword ? "new-password" : "current-password") + return ( {visibilityToggleable ? (