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:
Rasmus Langvad
2026-01-07 09:10:22 +00:00
parent 8c03a8b560
commit ffef566316
13 changed files with 665 additions and 206 deletions

View File

@@ -12,8 +12,8 @@ import { FormSelect } from "@scandic-hotels/design-system/Form/Select"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getLocalizedLanguageOptions } from "@/constants/languages"
import { PasswordInput } from "@scandic-hotels/design-system/PasswordInput"
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries"
import {
@@ -165,8 +165,14 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
defaultMessage: "Current password",
})}
name="password"
errorFormatter={formatFormErrorMessage}
/>
<PasswordInput
data-hj-suppress
isNewPassword
name="newPassword"
errorFormatter={formatFormErrorMessage}
/>
<PasswordInput data-hj-suppress isNewPassword name="newPassword" />
<PasswordInput
data-hj-suppress
label={intl.formatMessage({
@@ -174,6 +180,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
defaultMessage: "Retype new password",
})}
name="retypeNewPassword"
errorFormatter={formatFormErrorMessage}
/>
</section>
</>

View File

@@ -21,6 +21,7 @@ import CountrySelect from "@scandic-hotels/design-system/Form/Country"
import DateSelect from "@scandic-hotels/design-system/Form/Date"
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
import Phone from "@scandic-hotels/design-system/Form/Phone"
import { PasswordInput } from "@scandic-hotels/design-system/PasswordInput"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
import { toast } from "@scandic-hotels/design-system/Toast"
@@ -34,7 +35,6 @@ import {
} from "@scandic-hotels/trpc/routers/user/schemas"
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries"
import {
@@ -289,6 +289,7 @@ export default function SignupForm({
defaultMessage: "Password",
})}
isNewPassword
errorFormatter={formatFormErrorMessage}
/>
</section>

View File

@@ -1,119 +0,0 @@
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",
})}
/>
)
}

View File

@@ -1,159 +0,0 @@
"use client"
import { useState } from "react"
import { TextField } from "react-aria-components"
import {
Controller,
type RegisterOptions,
useFormContext,
} from "react-hook-form"
import { useIntl } from "react-intl"
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 { Typography } from "@scandic-hotels/design-system/Typography"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { NewPasswordValidation } from "./NewPasswordValidation"
import styles from "./passwordInput.module.css"
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean
isNewPassword?: boolean
}
export default function PasswordInput({
name = "password",
label,
"aria-label": ariaLabel,
disabled = false,
placeholder,
registerOptions = {},
visibilityToggleable = true,
isNewPassword = false,
className = "",
}: PasswordInputProps) {
const { control } = useFormContext()
const intl = useIntl()
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
return (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState, formState }) => {
const errors = isNewPassword
? Object.values(formState.errors[name]?.types ?? []).flat()
: []
return (
<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}
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}
aria-labelledby={field.name}
id={field.name}
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 ? (
<IconButton
variant="Muted"
emphasis
onPress={() => setIsPasswordVisible((value) => !value)}
aria-label={
isPasswordVisible
? intl.formatMessage({
id: "passwordInput.hidePassword",
defaultMessage: "Hide password",
})
: intl.formatMessage({
id: "passwordInput.showPassword",
defaultMessage: "Show password",
})
}
aria-controls={field.name}
className={styles.toggleButton}
iconName={isPasswordVisible ? "visibility_off" : "visibility"}
/>
) : null}
</div>
{isNewPassword ? (
<NewPasswordValidation value={field.value} errors={errors} />
) : null}
{isNewPassword ? (
!field.value && fieldState.error ? (
<ErrorMessage errorMessage={fieldState.error.message} />
) : null
) : fieldState.error ? (
<ErrorMessage errorMessage={fieldState.error.message} />
) : null}
</TextField>
)
}}
/>
)
}
function ErrorMessage({ errorMessage }: { errorMessage?: string }) {
const intl = useIntl()
return (
<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>
)
}

View File

@@ -1,41 +0,0 @@
.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;
}
.inputWrapper .toggleButton {
position: absolute;
right: var(--Space-x2);
top: 50%;
transform: translateY(-50%);
}
/* 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;
}

View File

@@ -1,3 +0,0 @@
import type { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
export type PasswordValidatorKey = keyof typeof passwordValidators