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

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

View File

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

View File

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