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:
@@ -12,7 +12,7 @@ import { InputLabel } from '../InputLabel'
|
||||
|
||||
import styles from './input.module.css'
|
||||
|
||||
import { IconButton } from '../IconButton'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { Typography } from '../Typography'
|
||||
import type { InputProps } from './types'
|
||||
import { clearInput, useInputHasValue } from './utils'
|
||||
@@ -105,15 +105,15 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
||||
</label>
|
||||
{showClearContentIcon && hasValue && (
|
||||
<div className={styles.rightIconContainer}>
|
||||
<IconButton
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rightIconButton}
|
||||
variant="Muted"
|
||||
emphasis
|
||||
onPress={onClearContent}
|
||||
onClick={onClearContent}
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
aria-label="Clear content"
|
||||
iconName="cancel"
|
||||
/>
|
||||
>
|
||||
<MaterialIcon icon="cancel" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||
@@ -153,15 +153,15 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
||||
</AriaLabel>
|
||||
{showClearContentIcon && hasValue && (
|
||||
<div className={styles.rightIconContainer}>
|
||||
<IconButton
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rightIconButton}
|
||||
variant="Muted"
|
||||
emphasis
|
||||
onPress={onClearContent}
|
||||
onClick={onClearContent}
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
aria-label="Clear content"
|
||||
iconName="cancel"
|
||||
/>
|
||||
>
|
||||
<MaterialIcon icon="cancel" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||
|
||||
@@ -131,6 +131,18 @@
|
||||
.rightIconButton {
|
||||
width: 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) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useIntl } from 'react-intl'
|
||||
|
||||
import { passwordValidators } from '@scandic-hotels/common/utils/zod/passwordValidator'
|
||||
import type { PasswordValidatorKey } from '@scandic-hotels/common/utils/zod/newPassword'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { Typography } from '../Typography'
|
||||
|
||||
import styles from './passwordInput.module.css'
|
||||
|
||||
export function NewPasswordValidation({
|
||||
value,
|
||||
errors,
|
||||
id,
|
||||
}: {
|
||||
value: string
|
||||
errors: string[]
|
||||
id: 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"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
id={id}
|
||||
>
|
||||
{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',
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PasswordInput } from './PasswordInput'
|
||||
@@ -0,0 +1,53 @@
|
||||
.helpText {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.error {
|
||||
align-items: center;
|
||||
color: var(--Text-Interactive-Error);
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
margin: var(--Space-x1) 0 0;
|
||||
}
|
||||
|
||||
.errors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Space-x15) var(--Space-x1);
|
||||
padding-top: var(--Space-x1);
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
position: absolute;
|
||||
right: var(--Space-x2);
|
||||
top: 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.
|
||||
* See: https://learn.microsoft.com/en-us/microsoft-edge/web-platform/password-reveal
|
||||
*/
|
||||
.inputWrapper input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
.inputWrapper input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
Reference in New Issue
Block a user