fix(SW-3693): Refactor Input stories to use FormInput component and showcase all controls * Refactor Input stories to use FormInput component and showcase all controls * Updated stories and added autocomplete prop to PasswordInput * Merge branch 'master' into fix/3693-group-inputs-storybook * Use FormTextArea in stories for TextArea to show description and error texts * Merged master into fix/3693-group-inputs-storybook * Merge branch 'master' into fix/3693-group-inputs-storybook * Removed redundant font name and fixed broken icons in stories * Merge branch 'fix/3693-group-inputs-storybook' of bitbucket.org:scandic-swap/web into fix/3693-group-inputs-storybook * Merged master into fix/3693-group-inputs-storybook * Merge branch 'master' into fix/3693-group-inputs-storybook Approved-by: Bianca Widstam
217 lines
6.6 KiB
TypeScript
217 lines
6.6 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { Text, TextField } from "react-aria-components"
|
|
import {
|
|
Controller,
|
|
type RegisterOptions,
|
|
useFormContext,
|
|
} from "react-hook-form"
|
|
import { useIntl, type IntlShape } from "react-intl"
|
|
|
|
import { MaterialIcon, type MaterialIconProps } 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
|
|
/** Helper text displayed below the input (hidden when there's an error) */
|
|
description?: string
|
|
descriptionIcon?: MaterialIconProps["icon"]
|
|
autoComplete?: string
|
|
}
|
|
|
|
export const PasswordInput = ({
|
|
name = "password",
|
|
label,
|
|
"aria-label": ariaLabel,
|
|
disabled = false,
|
|
placeholder,
|
|
registerOptions = {},
|
|
visibilityToggleable = true,
|
|
isNewPassword = false,
|
|
className = "",
|
|
errorFormatter,
|
|
description = "",
|
|
descriptionIcon = "info",
|
|
autoComplete,
|
|
}: PasswordInputProps) => {
|
|
const { control } = useFormContext()
|
|
const intl = useIntl()
|
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
|
|
|
|
const formatErrorMessage = errorFormatter ?? defaultErrorFormatter
|
|
|
|
// Automatically set autocomplete based on isNewPassword if not explicitly provided
|
|
const autocompleteValue =
|
|
autoComplete ?? (isNewPassword ? "new-password" : "current-password")
|
|
|
|
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
|
|
const showDescription = description && !fieldState.error
|
|
|
|
return (
|
|
<TextField
|
|
className={className}
|
|
aria-label={ariaLabel}
|
|
aria-invalid={hasError}
|
|
aria-describedby={describedBy}
|
|
isDisabled={field.disabled}
|
|
isInvalid={fieldState.invalid}
|
|
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"
|
|
}
|
|
autoComplete={autocompleteValue}
|
|
/>
|
|
{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>
|
|
|
|
{showDescription ? (
|
|
<Text className={styles.description} slot="description">
|
|
<MaterialIcon icon={descriptionIcon} size={20} />
|
|
{description}
|
|
</Text>
|
|
) : null}
|
|
|
|
{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>
|
|
)
|
|
}
|