Merged in feat/new-passwordinput-component (pull request #3376)

feat(SW-3672): Update PasswordInput component

* Update PasswordInput component

* Removed some tests not working as expected

* Remove IconButton from PasswordInput

* Remove IconButton from Input

* Merge branch 'master' into feat/new-passwordinput-component


Approved-by: Linus Flood
This commit is contained in:
Rasmus Langvad
2026-01-07 09:10:22 +00:00
parent 8c03a8b560
commit ffef566316
13 changed files with 665 additions and 206 deletions

View File

@@ -12,8 +12,8 @@ import { FormSelect } from "@scandic-hotels/design-system/Form/Select"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { getLocalizedLanguageOptions } from "@/constants/languages" import { getLocalizedLanguageOptions } from "@/constants/languages"
import { PasswordInput } from "@scandic-hotels/design-system/PasswordInput"
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries" import { getFormattedCountryList } from "@/utils/countries"
import { import {
@@ -165,8 +165,14 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
defaultMessage: "Current password", defaultMessage: "Current password",
})} })}
name="password" name="password"
errorFormatter={formatFormErrorMessage}
/>
<PasswordInput
data-hj-suppress
isNewPassword
name="newPassword"
errorFormatter={formatFormErrorMessage}
/> />
<PasswordInput data-hj-suppress isNewPassword name="newPassword" />
<PasswordInput <PasswordInput
data-hj-suppress data-hj-suppress
label={intl.formatMessage({ label={intl.formatMessage({
@@ -174,6 +180,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
defaultMessage: "Retype new password", defaultMessage: "Retype new password",
})} })}
name="retypeNewPassword" name="retypeNewPassword"
errorFormatter={formatFormErrorMessage}
/> />
</section> </section>
</> </>

View File

@@ -21,6 +21,7 @@ import CountrySelect from "@scandic-hotels/design-system/Form/Country"
import DateSelect from "@scandic-hotels/design-system/Form/Date" import DateSelect from "@scandic-hotels/design-system/Form/Date"
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput" import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
import Phone from "@scandic-hotels/design-system/Form/Phone" import Phone from "@scandic-hotels/design-system/Form/Phone"
import { PasswordInput } from "@scandic-hotels/design-system/PasswordInput"
import { TextLink } from "@scandic-hotels/design-system/TextLink" import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton" import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
import { toast } from "@scandic-hotels/design-system/Toast" import { toast } from "@scandic-hotels/design-system/Toast"
@@ -34,7 +35,6 @@ import {
} from "@scandic-hotels/trpc/routers/user/schemas" } from "@scandic-hotels/trpc/routers/user/schemas"
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly" import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries" import { getFormattedCountryList } from "@/utils/countries"
import { import {
@@ -289,6 +289,7 @@ export default function SignupForm({
defaultMessage: "Password", defaultMessage: "Password",
})} })}
isNewPassword isNewPassword
errorFormatter={formatFormErrorMessage}
/> />
</section> </section>

View File

@@ -1,159 +0,0 @@
"use client"
import { useState } from "react"
import { TextField } from "react-aria-components"
import {
Controller,
type RegisterOptions,
useFormContext,
} from "react-hook-form"
import { useIntl } from "react-intl"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Input } from "@scandic-hotels/design-system/Input"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { NewPasswordValidation } from "./NewPasswordValidation"
import styles from "./passwordInput.module.css"
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean
isNewPassword?: boolean
}
export default function PasswordInput({
name = "password",
label,
"aria-label": ariaLabel,
disabled = false,
placeholder,
registerOptions = {},
visibilityToggleable = true,
isNewPassword = false,
className = "",
}: PasswordInputProps) {
const { control } = useFormContext()
const intl = useIntl()
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
return (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState, formState }) => {
const errors = isNewPassword
? Object.values(formState.errors[name]?.types ?? []).flat()
: []
return (
<TextField
className={className}
aria-label={ariaLabel}
aria-invalid={!!fieldState.error}
aria-describedby="password-error password-requirements"
isDisabled={field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
type={
visibilityToggleable && isPasswordVisible ? "text" : "password"
}
>
<div className={styles.inputWrapper}>
<Input
{...field}
aria-labelledby={field.name}
id={field.name}
label={
label ||
(isNewPassword
? intl.formatMessage({
id: "passwordInput.newPasswordLabel",
defaultMessage: "New password",
})
: intl.formatMessage({
id: "common.password",
defaultMessage: "Password",
}))
}
placeholder={placeholder}
type={
visibilityToggleable && isPasswordVisible
? "text"
: "password"
}
/>
{visibilityToggleable ? (
<IconButton
variant="Muted"
emphasis
onPress={() => setIsPasswordVisible((value) => !value)}
aria-label={
isPasswordVisible
? intl.formatMessage({
id: "passwordInput.hidePassword",
defaultMessage: "Hide password",
})
: intl.formatMessage({
id: "passwordInput.showPassword",
defaultMessage: "Show password",
})
}
aria-controls={field.name}
className={styles.toggleButton}
iconName={isPasswordVisible ? "visibility_off" : "visibility"}
/>
) : null}
</div>
{isNewPassword ? (
<NewPasswordValidation value={field.value} errors={errors} />
) : null}
{isNewPassword ? (
!field.value && fieldState.error ? (
<ErrorMessage errorMessage={fieldState.error.message} />
) : null
) : fieldState.error ? (
<ErrorMessage errorMessage={fieldState.error.message} />
) : null}
</TextField>
)
}}
/>
)
}
function ErrorMessage({ errorMessage }: { errorMessage?: string }) {
const intl = useIntl()
return (
<Typography
variant="Body/Supporting text (caption)/smRegular"
className={styles.error}
>
<p role="alert" id="password-error">
<MaterialIcon
icon="info"
color="Icon/Feedback/Error"
aria-label={intl.formatMessage({
id: "common.error",
defaultMessage: "Error",
})}
/>
{getErrorMessage(intl, errorMessage)}
</p>
</Typography>
)
}

View File

@@ -12,7 +12,7 @@ import { InputLabel } from '../InputLabel'
import styles from './input.module.css' import styles from './input.module.css'
import { IconButton } from '../IconButton' import { MaterialIcon } from '../Icons/MaterialIcon'
import { Typography } from '../Typography' import { Typography } from '../Typography'
import type { InputProps } from './types' import type { InputProps } from './types'
import { clearInput, useInputHasValue } from './utils' import { clearInput, useInputHasValue } from './utils'
@@ -105,15 +105,15 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
</label> </label>
{showClearContentIcon && hasValue && ( {showClearContentIcon && hasValue && (
<div className={styles.rightIconContainer}> <div className={styles.rightIconContainer}>
<IconButton <button
type="button"
className={styles.rightIconButton} className={styles.rightIconButton}
variant="Muted" onClick={onClearContent}
emphasis
onPress={onClearContent}
// eslint-disable-next-line formatjs/no-literal-string-in-jsx // eslint-disable-next-line formatjs/no-literal-string-in-jsx
aria-label="Clear content" aria-label="Clear content"
iconName="cancel" >
/> <MaterialIcon icon="cancel" />
</button>
</div> </div>
)} )}
{rightIcon && !(showClearContentIcon && hasValue) && ( {rightIcon && !(showClearContentIcon && hasValue) && (
@@ -153,15 +153,15 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
</AriaLabel> </AriaLabel>
{showClearContentIcon && hasValue && ( {showClearContentIcon && hasValue && (
<div className={styles.rightIconContainer}> <div className={styles.rightIconContainer}>
<IconButton <button
type="button"
className={styles.rightIconButton} className={styles.rightIconButton}
variant="Muted" onClick={onClearContent}
emphasis
onPress={onClearContent}
// eslint-disable-next-line formatjs/no-literal-string-in-jsx // eslint-disable-next-line formatjs/no-literal-string-in-jsx
aria-label="Clear content" aria-label="Clear content"
iconName="cancel" >
/> <MaterialIcon icon="cancel" />
</button>
</div> </div>
)} )}
{rightIcon && !(showClearContentIcon && hasValue) && ( {rightIcon && !(showClearContentIcon && hasValue) && (

View File

@@ -131,6 +131,18 @@
.rightIconButton { .rightIconButton {
width: 24px; width: 24px;
height: 24px; height: 24px;
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.rightIconButton:focus-visible {
outline: 2px solid var(--Focus-ring-color, currentColor);
border-radius: 100%;
} }
@media (hover: hover) { @media (hover: hover) {

View File

@@ -1,19 +1,20 @@
import { useIntl } from "react-intl" import { useIntl } from 'react-intl'
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator" import { passwordValidators } from '@scandic-hotels/common/utils/zod/passwordValidator'
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import type { PasswordValidatorKey } from '@scandic-hotels/common/utils/zod/newPassword'
import { Typography } from "@scandic-hotels/design-system/Typography" import { MaterialIcon } from '../Icons/MaterialIcon'
import { Typography } from '../Typography'
import styles from "./passwordInput.module.css" import styles from './passwordInput.module.css'
import type { PasswordValidatorKey } from "@/types/components/form/newPassword"
export function NewPasswordValidation({ export function NewPasswordValidation({
value, value,
errors, errors,
id,
}: { }: {
value: string value: string
errors: string[] errors: string[]
id: string
}) { }) {
const intl = useIntl() const intl = useIntl()
@@ -21,59 +22,65 @@ export function NewPasswordValidation({
function getErrorMessage(key: PasswordValidatorKey) { function getErrorMessage(key: PasswordValidatorKey) {
switch (key) { switch (key) {
case "length": case 'length':
return intl.formatMessage( return intl.formatMessage(
{ {
id: "passwordInput.lengthRequirement", id: 'passwordInput.lengthRequirement',
defaultMessage: "{min} to {max} characters", defaultMessage: '{min} to {max} characters',
}, },
{ {
min: 10, min: 10,
max: 40, max: 40,
} }
) )
case "hasUppercase": case 'hasUppercase':
return intl.formatMessage( return intl.formatMessage(
{ {
id: "passwordInput.uppercaseRequirement", id: 'passwordInput.uppercaseRequirement',
defaultMessage: "{count} uppercase letter", defaultMessage: '{count} uppercase letter',
}, },
{ count: 1 } { count: 1 }
) )
case "hasLowercase": case 'hasLowercase':
return intl.formatMessage( return intl.formatMessage(
{ {
id: "passwordInput.lowercaseRequirement", id: 'passwordInput.lowercaseRequirement',
defaultMessage: "{count} lowercase letter", defaultMessage: '{count} lowercase letter',
}, },
{ count: 1 } { count: 1 }
) )
case "hasNumber": case 'hasNumber':
return intl.formatMessage( return intl.formatMessage(
{ {
id: "passwordInput.numberRequirement", id: 'passwordInput.numberRequirement',
defaultMessage: "{count} number", defaultMessage: '{count} number',
}, },
{ count: 1 } { count: 1 }
) )
case "hasSpecialChar": case 'hasSpecialChar':
return intl.formatMessage( return intl.formatMessage(
{ {
id: "passwordInput.specialCharacterRequirement", id: 'passwordInput.specialCharacterRequirement',
defaultMessage: "{count} special character", defaultMessage: '{count} special character',
}, },
{ count: 1 } { count: 1 }
) )
case "allowedCharacters": case 'allowedCharacters':
return intl.formatMessage({ return intl.formatMessage({
id: "passwordInput.allowedCharactersRequirement", id: 'passwordInput.allowedCharactersRequirement',
defaultMessage: "Only allowed characters", defaultMessage: 'Only allowed characters',
}) })
} }
} }
return ( return (
<div className={styles.errors} role="status" id="password-requirements"> <div
className={styles.errors}
role="status"
aria-live="polite"
aria-atomic="false"
id={id}
>
{Object.entries(passwordValidators).map(([key, { message }]) => ( {Object.entries(passwordValidators).map(([key, { message }]) => (
<Typography variant="Label/xsRegular" key={key}> <Typography variant="Label/xsRegular" key={key}>
<span className={styles.helpText}> <span className={styles.helpText}>
@@ -100,8 +107,8 @@ function Icon({ errorMessage, errors }: IconProps) {
size={20} size={20}
role="img" role="img"
aria-label={intl.formatMessage({ aria-label={intl.formatMessage({
id: "common.error", id: 'common.error',
defaultMessage: "Error", defaultMessage: 'Error',
})} })}
/> />
) : ( ) : (
@@ -111,8 +118,8 @@ function Icon({ errorMessage, errors }: IconProps) {
size={20} size={20}
role="img" role="img"
aria-label={intl.formatMessage({ aria-label={intl.formatMessage({
id: "common.success", id: 'common.success',
defaultMessage: "Success", defaultMessage: 'Success',
})} })}
/> />
) )

View File

@@ -0,0 +1,281 @@
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'
import { z } from 'zod'
import type { IntlShape } from 'react-intl'
import { PasswordInput } from './index'
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 ?? ''
// ============================================================================
// 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
}
function PasswordInputComponent({
onSubmit,
showErrors = false,
defaultNewPassword = '',
}: PasswordInputProps) {
const methods = useForm<PasswordFormData>({
resolver: zodResolver(passwordFormSchema),
defaultValues: {
currentPassword: '',
newPassword: defaultNewPassword,
confirmPassword: '',
},
mode: 'all',
criteriaMode: 'all',
reValidateMode: 'onChange',
})
// Trigger validation on mount if showErrors is true
useEffect(() => {
if (showErrors) {
methods.trigger()
}
}, [showErrors, methods])
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 passwordFormMeta: Meta<typeof PasswordInputComponent> = {
title: 'Core Components/PasswordInput',
component: PasswordInputComponent,
parameters: {
layout: 'padded',
},
argTypes: {
showErrors: {
control: 'boolean',
description: 'Show validation errors on mount',
},
defaultNewPassword: {
control: 'text',
description: 'Default value for new password field',
},
},
}
export default passwordFormMeta
type PasswordFormStory = StoryObj<typeof PasswordInputComponent>
export const Default: PasswordFormStory = {
render: (args) => <PasswordInputComponent {...args} />,
args: {
onSubmit: fn(),
showErrors: false,
defaultNewPassword: '',
},
}
// ============================================================================
// Password Validation Showcase
// ============================================================================
const showcasePasswordSchema = z.object({
empty: z.literal('').optional().or(passwordValidator()),
weak: passwordValidator(),
partial: passwordValidator(),
valid: passwordValidator(),
tooLong: passwordValidator(),
})
type ShowcasePasswordFormData = z.infer<typeof showcasePasswordSchema>
function PasswordValidationShowcase() {
const methods = useForm<ShowcasePasswordFormData>({
resolver: zodResolver(showcasePasswordSchema),
defaultValues: {
empty: '',
weak: 'weak',
partial: 'Password1',
valid: 'ValidPassword123!',
tooLong: 'A'.repeat(41) + '1!',
},
mode: 'all',
criteriaMode: 'all',
reValidateMode: 'onChange',
})
// Trigger validation on mount to show validation states
useEffect(() => {
methods.trigger(['weak', 'partial', 'valid', 'tooLong'])
}, [methods])
return (
<FormProvider {...methods}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '3rem',
maxWidth: '800px',
padding: '2rem',
}}
>
<section>
<Typography variant="Title/md">
<h2>New Password Validation States</h2>
</Typography>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))',
gap: '2rem',
marginTop: '1rem',
}}
>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>Empty Password</p>
</Typography>
<PasswordInput
name="empty"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
</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>
</Typography>
<PasswordInput
name="valid"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
</div>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>Too Long Password</p>
</Typography>
<PasswordInput
name="tooLong"
isNewPassword
errorFormatter={defaultErrorFormatter}
/>
</div>
</div>
</section>
</div>
</FormProvider>
)
}
const showcaseMeta: Meta<typeof PasswordValidationShowcase> = {
title: 'Core Components/PasswordInput',
component: PasswordValidationShowcase,
parameters: {
layout: 'fullscreen',
},
}
type ShowcaseStory = StoryObj<typeof PasswordValidationShowcase>
export const AllValidationStates: ShowcaseStory = {
render: () => <PasswordValidationShowcase />,
parameters: {
...showcaseMeta.parameters,
},
}

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FormProvider, useForm } from 'react-hook-form'
import { IntlProvider } from 'react-intl'
import { PasswordInput } from './PasswordInput'
afterEach(() => {
cleanup()
})
function FormWrapper({
children,
defaultValues = { password: '' },
}: {
children: React.ReactNode
defaultValues?: Record<string, string>
}) {
const methods = useForm({ defaultValues })
return (
<IntlProvider locale="en" messages={{}}>
<FormProvider {...methods}>
<form>{children}</form>
</FormProvider>
</IntlProvider>
)
}
const renderPasswordInput = (
props: React.ComponentProps<typeof PasswordInput> = {},
defaultValues?: Record<string, string>
) => {
return render(
<FormWrapper defaultValues={defaultValues}>
<PasswordInput {...props} />
</FormWrapper>
)
}
describe('PasswordInput', () => {
describe('rendering', () => {
it('renders with default password label', () => {
renderPasswordInput()
expect(screen.getByLabelText('Password')).toBeTruthy()
})
it('renders with custom label', () => {
renderPasswordInput({ label: 'Enter your password' })
expect(screen.getByLabelText('Enter your password')).toBeTruthy()
})
it('renders with new password label when isNewPassword is true', () => {
renderPasswordInput({ isNewPassword: true })
expect(screen.getByLabelText('New password')).toBeTruthy()
})
})
describe('visibility toggle', () => {
it('shows visibility toggle button by default', () => {
renderPasswordInput()
expect(screen.getByLabelText('Show password')).toBeTruthy()
})
it('hides visibility toggle when visibilityToggleable is false', () => {
renderPasswordInput({ visibilityToggleable: false })
expect(screen.queryByLabelText('Show password')).toBeNull()
expect(screen.queryByLabelText('Hide password')).toBeNull()
})
})
describe('disabled state', () => {
it('disables the input when disabled prop is true', () => {
renderPasswordInput({ disabled: true })
expect(screen.getByLabelText('Password')).toHaveProperty('disabled', true)
})
})
describe('form integration', () => {
it('updates form value when typing', async () => {
const user = userEvent.setup()
renderPasswordInput()
const input = screen.getByLabelText('Password')
await user.type(input, 'secret123')
expect(input).toHaveProperty('value', 'secret123')
})
it('uses custom name prop', async () => {
const user = userEvent.setup()
renderPasswordInput({ name: 'confirmPassword' }, { confirmPassword: '' })
const input = screen.getByLabelText('Password')
await user.type(input, 'test')
expect(input).toHaveProperty('value', 'test')
})
})
})

View File

@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import { TextField } from 'react-aria-components'
import {
Controller,
type RegisterOptions,
useFormContext,
} from 'react-hook-form'
import { useIntl, type IntlShape } from 'react-intl'
import { MaterialIcon } from '../Icons/MaterialIcon'
import { Input } from '../Input'
import { Typography } from '../Typography'
import { NewPasswordValidation } from './NewPasswordValidation'
import styles from './passwordInput.module.css'
const defaultErrorFormatter = (
_intl: IntlShape,
errorMessage?: string
): string => errorMessage ?? ''
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean
isNewPassword?: boolean
errorFormatter?: (intl: IntlShape, errorMessage?: string) => string
}
export const PasswordInput = ({
name = 'password',
label,
'aria-label': ariaLabel,
disabled = false,
placeholder,
registerOptions = {},
visibilityToggleable = true,
isNewPassword = false,
className = '',
errorFormatter,
}: PasswordInputProps) => {
const { control } = useFormContext()
const intl = useIntl()
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const formatErrorMessage = errorFormatter ?? defaultErrorFormatter
return (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState, formState }) => {
const errors = isNewPassword
? Object.values(formState.errors[name]?.types ?? []).flat()
: []
// Use field.name as base for all IDs - it's already unique per form field
const errorId = `${field.name}-error`
const requirementsId = `${field.name}-requirements`
const inputId = field.name // Already used on line 85
// Build aria-describedby dynamically based on what exists
const describedBy =
[
fieldState.error ? errorId : null,
isNewPassword && field.value ? requirementsId : null,
]
.filter(Boolean)
.join(' ') || undefined
const hasError = !!fieldState.error
const showRequirements = isNewPassword && !!field.value
return (
<TextField
className={className}
aria-label={ariaLabel}
aria-invalid={hasError}
aria-describedby={describedBy}
isDisabled={field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
type={
visibilityToggleable && isPasswordVisible ? 'text' : 'password'
}
>
<div className={styles.inputWrapper}>
<Input
{...field}
id={inputId}
label={
label ||
(isNewPassword
? intl.formatMessage({
id: 'passwordInput.newPasswordLabel',
defaultMessage: 'New password',
})
: intl.formatMessage({
id: 'common.password',
defaultMessage: 'Password',
}))
}
placeholder={placeholder}
type={
visibilityToggleable && isPasswordVisible
? 'text'
: 'password'
}
/>
{visibilityToggleable ? (
<button
type="button"
onClick={() => setIsPasswordVisible((value) => !value)}
aria-label={
isPasswordVisible
? intl.formatMessage({
id: 'passwordInput.hidePassword',
defaultMessage: 'Hide password',
})
: intl.formatMessage({
id: 'passwordInput.showPassword',
defaultMessage: 'Show password',
})
}
aria-controls={inputId}
aria-expanded={isPasswordVisible}
className={styles.toggleButton}
>
<MaterialIcon
icon={isPasswordVisible ? 'visibility_off' : 'visibility'}
size={24}
/>
</button>
) : null}
</div>
{showRequirements ? (
<NewPasswordValidation
value={field.value}
errors={errors}
id={requirementsId}
/>
) : null}
{hasError && (!isNewPassword || !field.value) ? (
<ErrorMessage
errorMessage={fieldState.error?.message}
formatErrorMessage={formatErrorMessage}
id={errorId}
/>
) : null}
</TextField>
)
}}
/>
)
}
function ErrorMessage({
errorMessage,
formatErrorMessage,
id,
}: {
errorMessage?: string
formatErrorMessage: (intl: IntlShape, errorMessage?: string) => string
id: string
}) {
const intl = useIntl()
return (
<Typography
variant="Body/Supporting text (caption)/smRegular"
className={styles.error}
>
<p role="alert" id={id} aria-atomic="true">
<MaterialIcon
icon="info"
color="Icon/Feedback/Error"
aria-label={intl.formatMessage({
id: 'common.error',
defaultMessage: 'Error',
})}
/>
{formatErrorMessage(intl, errorMessage)}
</p>
</Typography>
)
}

View File

@@ -0,0 +1 @@
export { PasswordInput } from './PasswordInput'

View File

@@ -23,11 +23,23 @@
position: relative; position: relative;
} }
.inputWrapper .toggleButton { .toggleButton {
position: absolute; position: absolute;
right: var(--Space-x2); right: var(--Space-x2);
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.toggleButton:focus-visible {
outline: 2px solid var(--Focus-ring-color, currentColor);
border-radius: 100%;
} }
/* Hide the built-in password reveal icon in Microsoft Edge. /* Hide the built-in password reveal icon in Microsoft Edge.

View File

@@ -162,6 +162,7 @@
"./OldDSLink": "./lib/components/OldDSLink/index.tsx", "./OldDSLink": "./lib/components/OldDSLink/index.tsx",
"./OpeningHours": "./lib/components/OpeningHours/index.tsx", "./OpeningHours": "./lib/components/OpeningHours/index.tsx",
"./ParkingInformation": "./lib/components/ParkingInformation/index.tsx", "./ParkingInformation": "./lib/components/ParkingInformation/index.tsx",
"./PasswordInput": "./lib/components/PasswordInput/index.tsx",
"./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx", "./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx",
"./PointsRateCard": "./lib/components/RateCard/Points/index.tsx", "./PointsRateCard": "./lib/components/RateCard/Points/index.tsx",
"./Progress": "./lib/components/Progress/index.tsx", "./Progress": "./lib/components/Progress/index.tsx",