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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user