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"
import { useState } from "react"
import { Text, TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { TextField } from "react-aria-components"
import {
Controller,
type RegisterOptions,
useFormContext,
} from "react-hook-form"
import { useIntl } from "react-intl"
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
import Caption from "@scandic-hotels/design-system/Caption"
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 { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { NewPasswordValidation } from "./NewPasswordValidation"
import styles from "./passwordInput.module.css"
import type { PasswordValidatorKey } from "@/types/components/form/newPassword"
import type { IconProps, PasswordInputProps } from "./passwordInput"
interface PasswordInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean
isNewPassword?: boolean
}
export default function PasswordInput({
name = "password",
@@ -48,6 +58,8 @@ export default function PasswordInput({
<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}
@@ -85,11 +97,8 @@ export default function PasswordInput({
}
/>
{visibilityToggleable ? (
<Button
type="button"
variant="icon"
size="small"
intent="tertiary"
<IconButton
theme="Black"
onClick={() => setIsPasswordVisible((value) => !value)}
aria-label={
isPasswordVisible
@@ -105,12 +114,11 @@ export default function PasswordInput({
aria-controls={field.name}
className={styles.toggleButton}
>
{isPasswordVisible ? (
<MaterialIcon icon="visibility_off" />
) : (
<MaterialIcon icon="visibility" />
)}
</Button>
<MaterialIcon
icon={isPasswordVisible ? "visibility_off" : "visibility"}
size={24}
/>
</IconButton>
) : null}
</div>
@@ -120,16 +128,10 @@ export default function PasswordInput({
{isNewPassword ? (
!field.value && fieldState.error ? (
<Caption className={styles.error} fontOnly>
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
{getErrorMessage(intl, fieldState.error.message)}
</Caption>
<ErrorMessage errorMessage={fieldState.error.message} />
) : null
) : fieldState.error ? (
<Caption className={styles.error} fontOnly>
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
{getErrorMessage(intl, fieldState.error.message)}
</Caption>
<ErrorMessage errorMessage={fieldState.error.message} />
) : null}
</TextField>
)
@@ -138,88 +140,24 @@ export default function PasswordInput({
)
}
function Icon({ errorMessage, errors }: IconProps) {
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[]
}) {
function ErrorMessage({ errorMessage }: { errorMessage?: 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}>
{Object.entries(passwordValidators).map(([key, { message }]) => (
<Caption asChild color="black" key={key}>
<Text className={styles.helpText} slot="description">
<Icon errorMessage={message} errors={errors} />
{getErrorMessage(key as PasswordValidatorKey)}
</Text>
</Caption>
))}
</div>
<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

@@ -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 type { VariantProps } from 'class-variance-authority'
import { HTMLAttributes } from 'react'
export interface MaterialIconProps
extends Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>,
Omit<HTMLAttributes<HTMLSpanElement>, 'color' | 'id'>,
VariantProps<typeof iconVariants> {
isFilled?: boolean
}
@@ -18,6 +20,12 @@ export function MaterialIcon({
...props
}: MaterialIconProps) {
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 (
// The span is used to prevent the MaterialSymbol from being underlined when used inside a link or button
<span>
@@ -25,8 +33,9 @@ export function MaterialIcon({
className={classNames}
data-testid="MaterialIcon"
size={size}
{...props}
fill={isFilled}
aria-hidden={computedAriaHidden}
{...props}
/>
</span>
)