Merged in fix/LOY-382-password-validation-audit (pull request #3034)

Fix/LOY-382: Refactoring + accessibility of PasswordInput

* fix(LOY-382): fix password accessibility issues

* chore(LOY-382): refactor & add accessibility

* refactor(LOY-382)


Approved-by: Erik Tiekstra
This commit is contained in:
Matilda Landström
2025-10-31 10:19:32 +00:00
parent 7abe190bed
commit 3590a88031
4 changed files with 174 additions and 122 deletions

View File

@@ -0,0 +1,119 @@
import { useIntl } from "react-intl"
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./passwordInput.module.css"
import type { PasswordValidatorKey } from "@/types/components/form/newPassword"
export function NewPasswordValidation({
value,
errors,
}: {
value: string
errors: string[]
}) {
const intl = useIntl()
if (!value) return null
function getErrorMessage(key: PasswordValidatorKey) {
switch (key) {
case "length":
return intl.formatMessage(
{
id: "passwordInput.lengthRequirement",
defaultMessage: "{min} to {max} characters",
},
{
min: 10,
max: 40,
}
)
case "hasUppercase":
return intl.formatMessage(
{
id: "passwordInput.uppercaseRequirement",
defaultMessage: "{count} uppercase letter",
},
{ count: 1 }
)
case "hasLowercase":
return intl.formatMessage(
{
id: "passwordInput.lowercaseRequirement",
defaultMessage: "{count} lowercase letter",
},
{ count: 1 }
)
case "hasNumber":
return intl.formatMessage(
{
id: "passwordInput.numberRequirement",
defaultMessage: "{count} number",
},
{ count: 1 }
)
case "hasSpecialChar":
return intl.formatMessage(
{
id: "passwordInput.specialCharacterRequirement",
defaultMessage: "{count} special character",
},
{ count: 1 }
)
case "allowedCharacters":
return intl.formatMessage({
id: "passwordInput.allowedCharactersRequirement",
defaultMessage: "Only allowed characters",
})
}
}
return (
<div className={styles.errors} role="status" id="password-requirements">
{Object.entries(passwordValidators).map(([key, { message }]) => (
<Typography variant="Label/xsRegular" key={key}>
<span className={styles.helpText}>
<Icon errorMessage={message} errors={errors} />
{getErrorMessage(key as PasswordValidatorKey)}
</span>
</Typography>
))}
</div>
)
}
interface IconProps {
errorMessage: string
errors: string[]
}
function Icon({ errorMessage, errors }: IconProps) {
const intl = useIntl()
return errors.includes(errorMessage) ? (
<MaterialIcon
icon="close"
color="Icon/Feedback/Error"
size={20}
role="img"
aria-label={intl.formatMessage({
id: "common.error",
defaultMessage: "Error",
})}
/>
) : (
<MaterialIcon
icon="check"
color="Icon/Feedback/Success"
size={20}
role="img"
aria-label={intl.formatMessage({
id: "common.success",
defaultMessage: "Success",
})}
/>
)
}

View File

@@ -1,22 +1,32 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { Text, TextField } from "react-aria-components" import { TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form" import {
Controller,
type RegisterOptions,
useFormContext,
} from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator" import { IconButton } from "@scandic-hotels/design-system/IconButton"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Input } from "@scandic-hotels/design-system/Input" import { Input } from "@scandic-hotels/design-system/Input"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton" import { Typography } from "@scandic-hotels/design-system/Typography"
import { getErrorMessage } from "@/utils/getErrorMessage" import { getErrorMessage } from "@/utils/getErrorMessage"
import { NewPasswordValidation } from "./NewPasswordValidation"
import styles from "./passwordInput.module.css" import styles from "./passwordInput.module.css"
import type { PasswordValidatorKey } from "@/types/components/form/newPassword" interface PasswordInputProps
import type { IconProps, PasswordInputProps } from "./passwordInput" extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean
isNewPassword?: boolean
}
export default function PasswordInput({ export default function PasswordInput({
name = "password", name = "password",
@@ -48,6 +58,8 @@ export default function PasswordInput({
<TextField <TextField
className={className} className={className}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-invalid={!!fieldState.error}
aria-describedby="password-error password-requirements"
isDisabled={field.disabled} isDisabled={field.disabled}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required} isRequired={!!registerOptions.required}
@@ -85,11 +97,8 @@ export default function PasswordInput({
} }
/> />
{visibilityToggleable ? ( {visibilityToggleable ? (
<Button <IconButton
type="button" theme="Black"
variant="icon"
size="small"
intent="tertiary"
onClick={() => setIsPasswordVisible((value) => !value)} onClick={() => setIsPasswordVisible((value) => !value)}
aria-label={ aria-label={
isPasswordVisible isPasswordVisible
@@ -105,12 +114,11 @@ export default function PasswordInput({
aria-controls={field.name} aria-controls={field.name}
className={styles.toggleButton} className={styles.toggleButton}
> >
{isPasswordVisible ? ( <MaterialIcon
<MaterialIcon icon="visibility_off" /> icon={isPasswordVisible ? "visibility_off" : "visibility"}
) : ( size={24}
<MaterialIcon icon="visibility" /> />
)} </IconButton>
</Button>
) : null} ) : null}
</div> </div>
@@ -120,16 +128,10 @@ export default function PasswordInput({
{isNewPassword ? ( {isNewPassword ? (
!field.value && fieldState.error ? ( !field.value && fieldState.error ? (
<Caption className={styles.error} fontOnly> <ErrorMessage errorMessage={fieldState.error.message} />
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
{getErrorMessage(intl, fieldState.error.message)}
</Caption>
) : null ) : null
) : fieldState.error ? ( ) : fieldState.error ? (
<Caption className={styles.error} fontOnly> <ErrorMessage errorMessage={fieldState.error.message} />
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
{getErrorMessage(intl, fieldState.error.message)}
</Caption>
) : null} ) : null}
</TextField> </TextField>
) )
@@ -138,88 +140,24 @@ export default function PasswordInput({
) )
} }
function Icon({ errorMessage, errors }: IconProps) { function ErrorMessage({ errorMessage }: { errorMessage?: string }) {
return errors.includes(errorMessage) ? (
<MaterialIcon icon="close" color="Icon/Interactive/Accent" size={20} />
) : (
<MaterialIcon icon="check" color="Icon/Feedback/Success" size={20} />
)
}
function NewPasswordValidation({
value,
errors,
}: {
value: string
errors: string[]
}) {
const intl = useIntl() const intl = useIntl()
if (!value) return null
function getErrorMessage(key: PasswordValidatorKey) {
switch (key) {
case "length":
return intl.formatMessage(
{
id: "passwordInput.lengthRequirement",
defaultMessage: "{min} to {max} characters",
},
{
min: 10,
max: 40,
}
)
case "hasUppercase":
return intl.formatMessage(
{
id: "passwordInput.uppercaseRequirement",
defaultMessage: "{count} uppercase letter",
},
{ count: 1 }
)
case "hasLowercase":
return intl.formatMessage(
{
id: "passwordInput.lowercaseRequirement",
defaultMessage: "{count} lowercase letter",
},
{ count: 1 }
)
case "hasNumber":
return intl.formatMessage(
{
id: "passwordInput.numberRequirement",
defaultMessage: "{count} number",
},
{ count: 1 }
)
case "hasSpecialChar":
return intl.formatMessage(
{
id: "passwordInput.specialCharacterRequirement",
defaultMessage: "{count} special character",
},
{ count: 1 }
)
case "allowedCharacters":
return intl.formatMessage({
id: "passwordInput.allowedCharactersRequirement",
defaultMessage: "Only allowed characters",
})
}
}
return ( return (
<div className={styles.errors}> <Typography
{Object.entries(passwordValidators).map(([key, { message }]) => ( variant="Body/Supporting text (caption)/smRegular"
<Caption asChild color="black" key={key}> className={styles.error}
<Text className={styles.helpText} slot="description"> >
<Icon errorMessage={message} errors={errors} /> <p role="alert" id="password-error">
{getErrorMessage(key as PasswordValidatorKey)} <MaterialIcon
</Text> icon="info"
</Caption> color="Icon/Feedback/Error"
))} aria-label={intl.formatMessage({
</div> id: "common.error",
defaultMessage: "Error",
})}
/>
{getErrorMessage(intl, errorMessage)}
</p>
</Typography>
) )
} }

View File

@@ -1,14 +0,0 @@
import type { RegisterOptions } from "react-hook-form"
export interface PasswordInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean
isNewPassword?: boolean
}
export interface IconProps {
errorMessage: string
errors: string[]
}

View File

@@ -3,9 +3,11 @@ import { MaterialSymbol, type MaterialSymbolProps } from './MaterialSymbol'
import { iconVariants } from '../variants' import { iconVariants } from '../variants'
import type { VariantProps } from 'class-variance-authority' import type { VariantProps } from 'class-variance-authority'
import { HTMLAttributes } from 'react'
export interface MaterialIconProps export interface MaterialIconProps
extends Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>, extends Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>,
Omit<HTMLAttributes<HTMLSpanElement>, 'color' | 'id'>,
VariantProps<typeof iconVariants> { VariantProps<typeof iconVariants> {
isFilled?: boolean isFilled?: boolean
} }
@@ -18,6 +20,12 @@ export function MaterialIcon({
...props ...props
}: MaterialIconProps) { }: MaterialIconProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
const { role, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden } = props
// Automatically decide whether to hide from assistive tech
const computedAriaHidden =
ariaHidden !== undefined ? ariaHidden : ariaLabel || role ? false : true
return ( return (
// The span is used to prevent the MaterialSymbol from being underlined when used inside a link or button // The span is used to prevent the MaterialSymbol from being underlined when used inside a link or button
<span> <span>
@@ -25,8 +33,9 @@ export function MaterialIcon({
className={classNames} className={classNames}
data-testid="MaterialIcon" data-testid="MaterialIcon"
size={size} size={size}
{...props}
fill={isFilled} fill={isFilled}
aria-hidden={computedAriaHidden}
{...props}
/> />
</span> </span>
) )