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:
@@ -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<typeof passwordFormSchema>
|
||||
|
||||
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<PasswordFormData>({
|
||||
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 (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
maxWidth: "500px",
|
||||
}}
|
||||
>
|
||||
<Typography variant="Title/md">
|
||||
<h2>Change Password</h2>
|
||||
</Typography>
|
||||
|
||||
<div style={{ padding: "1rem" }}>
|
||||
<PasswordInput
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
name="password"
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
visibilityToggleable={visibilityToggleable}
|
||||
isNewPassword={isNewPassword}
|
||||
registerOptions={required ? { required: true } : undefined}
|
||||
errorFormatter={defaultErrorFormatter}
|
||||
description={description}
|
||||
descriptionIcon={descriptionIcon}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const passwordFormMeta: Meta<typeof PasswordInputComponent> = {
|
||||
title: "Core Components/PasswordInput",
|
||||
const meta: Meta<typeof PasswordInputComponent> = {
|
||||
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<typeof PasswordInputComponent>
|
||||
type Story = StoryObj<typeof PasswordInputComponent>
|
||||
|
||||
export const Default: PasswordFormStory = {
|
||||
render: (args) => <PasswordInputComponent {...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() {
|
||||
}}
|
||||
>
|
||||
<section>
|
||||
<Typography variant="Title/md">
|
||||
<h2>New Password Validation States</h2>
|
||||
<Typography variant="Title/sm">
|
||||
<h2>Password Validation States</h2>
|
||||
</Typography>
|
||||
<div
|
||||
style={{
|
||||
@@ -213,28 +311,6 @@ function PasswordValidationShowcase() {
|
||||
/>
|
||||
</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>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>Valid Password</p>
|
||||
@@ -246,6 +322,17 @@ function PasswordValidationShowcase() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>Too Short Password</p>
|
||||
</Typography>
|
||||
<PasswordInput
|
||||
name="weak"
|
||||
isNewPassword
|
||||
errorFormatter={defaultErrorFormatter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>Too Long Password</p>
|
||||
|
||||
@@ -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<HTMLInputElement>
|
||||
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 (
|
||||
<Controller
|
||||
disabled={disabled}
|
||||
@@ -75,6 +86,7 @@ export const PasswordInput = ({
|
||||
|
||||
const hasError = !!fieldState.error
|
||||
const showRequirements = isNewPassword && !!field.value
|
||||
const showDescription = description && !fieldState.error
|
||||
|
||||
return (
|
||||
<TextField
|
||||
@@ -115,6 +127,7 @@ export const PasswordInput = ({
|
||||
? "text"
|
||||
: "password"
|
||||
}
|
||||
autoComplete={autocompleteValue}
|
||||
/>
|
||||
{visibilityToggleable ? (
|
||||
<button
|
||||
@@ -143,6 +156,13 @@ export const PasswordInput = ({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showDescription ? (
|
||||
<Text className={styles.description} slot="description">
|
||||
<MaterialIcon icon={descriptionIcon} size={20} />
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{showRequirements ? (
|
||||
<NewPasswordValidation
|
||||
value={field.value}
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
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 {
|
||||
align-items: center;
|
||||
color: var(--Text-Interactive-Error);
|
||||
|
||||
Reference in New Issue
Block a user