Files
web/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx
Rasmus Langvad b966cf1d53 Merged in fix/3693-group-inputs-storybook (pull request #3402)
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
2026-01-21 16:20:04 +00:00

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>
)
}