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:
@@ -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",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user