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,8 +12,8 @@ import { FormSelect } from "@scandic-hotels/design-system/Form/Select"
|
|||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { getLocalizedLanguageOptions } from "@/constants/languages"
|
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 useLang from "@/hooks/useLang"
|
||||||
import { getFormattedCountryList } from "@/utils/countries"
|
import { getFormattedCountryList } from "@/utils/countries"
|
||||||
import {
|
import {
|
||||||
@@ -165,8 +165,14 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
|
|||||||
defaultMessage: "Current password",
|
defaultMessage: "Current password",
|
||||||
})}
|
})}
|
||||||
name="password"
|
name="password"
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
data-hj-suppress
|
||||||
|
isNewPassword
|
||||||
|
name="newPassword"
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
<PasswordInput data-hj-suppress isNewPassword name="newPassword" />
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
data-hj-suppress
|
data-hj-suppress
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
@@ -174,6 +180,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
|
|||||||
defaultMessage: "Retype new password",
|
defaultMessage: "Retype new password",
|
||||||
})}
|
})}
|
||||||
name="retypeNewPassword"
|
name="retypeNewPassword"
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
|||||||
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
||||||
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
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 { TextLink } from "@scandic-hotels/design-system/TextLink"
|
||||||
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
|
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
|
||||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||||
@@ -34,7 +35,6 @@ import {
|
|||||||
} from "@scandic-hotels/trpc/routers/user/schemas"
|
} from "@scandic-hotels/trpc/routers/user/schemas"
|
||||||
|
|
||||||
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
|
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
|
||||||
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
import { getFormattedCountryList } from "@/utils/countries"
|
import { getFormattedCountryList } from "@/utils/countries"
|
||||||
import {
|
import {
|
||||||
@@ -289,6 +289,7 @@ export default function SignupForm({
|
|||||||
defaultMessage: "Password",
|
defaultMessage: "Password",
|
||||||
})}
|
})}
|
||||||
isNewPassword
|
isNewPassword
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import { InputLabel } from '../InputLabel'
|
|||||||
|
|
||||||
import styles from './input.module.css'
|
import styles from './input.module.css'
|
||||||
|
|
||||||
import { IconButton } from '../IconButton'
|
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||||
import { Typography } from '../Typography'
|
import { Typography } from '../Typography'
|
||||||
import type { InputProps } from './types'
|
import type { InputProps } from './types'
|
||||||
import { clearInput, useInputHasValue } from './utils'
|
import { clearInput, useInputHasValue } from './utils'
|
||||||
@@ -105,15 +105,15 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
|||||||
</label>
|
</label>
|
||||||
{showClearContentIcon && hasValue && (
|
{showClearContentIcon && hasValue && (
|
||||||
<div className={styles.rightIconContainer}>
|
<div className={styles.rightIconContainer}>
|
||||||
<IconButton
|
<button
|
||||||
|
type="button"
|
||||||
className={styles.rightIconButton}
|
className={styles.rightIconButton}
|
||||||
variant="Muted"
|
onClick={onClearContent}
|
||||||
emphasis
|
|
||||||
onPress={onClearContent}
|
|
||||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
aria-label="Clear content"
|
aria-label="Clear content"
|
||||||
iconName="cancel"
|
>
|
||||||
/>
|
<MaterialIcon icon="cancel" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||||
@@ -153,15 +153,15 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
|||||||
</AriaLabel>
|
</AriaLabel>
|
||||||
{showClearContentIcon && hasValue && (
|
{showClearContentIcon && hasValue && (
|
||||||
<div className={styles.rightIconContainer}>
|
<div className={styles.rightIconContainer}>
|
||||||
<IconButton
|
<button
|
||||||
|
type="button"
|
||||||
className={styles.rightIconButton}
|
className={styles.rightIconButton}
|
||||||
variant="Muted"
|
onClick={onClearContent}
|
||||||
emphasis
|
|
||||||
onPress={onClearContent}
|
|
||||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
aria-label="Clear content"
|
aria-label="Clear content"
|
||||||
iconName="cancel"
|
>
|
||||||
/>
|
<MaterialIcon icon="cancel" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||||
|
|||||||
@@ -131,6 +131,18 @@
|
|||||||
.rightIconButton {
|
.rightIconButton {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 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) {
|
@media (hover: hover) {
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from 'react-intl'
|
||||||
|
|
||||||
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
|
import { passwordValidators } from '@scandic-hotels/common/utils/zod/passwordValidator'
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import type { PasswordValidatorKey } from '@scandic-hotels/common/utils/zod/newPassword'
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||||
|
import { Typography } from '../Typography'
|
||||||
|
|
||||||
import styles from "./passwordInput.module.css"
|
import styles from './passwordInput.module.css'
|
||||||
|
|
||||||
import type { PasswordValidatorKey } from "@/types/components/form/newPassword"
|
|
||||||
|
|
||||||
export function NewPasswordValidation({
|
export function NewPasswordValidation({
|
||||||
value,
|
value,
|
||||||
errors,
|
errors,
|
||||||
|
id,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
errors: string[]
|
errors: string[]
|
||||||
|
id: string
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
@@ -21,59 +22,65 @@ export function NewPasswordValidation({
|
|||||||
|
|
||||||
function getErrorMessage(key: PasswordValidatorKey) {
|
function getErrorMessage(key: PasswordValidatorKey) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "length":
|
case 'length':
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "passwordInput.lengthRequirement",
|
id: 'passwordInput.lengthRequirement',
|
||||||
defaultMessage: "{min} to {max} characters",
|
defaultMessage: '{min} to {max} characters',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
min: 10,
|
min: 10,
|
||||||
max: 40,
|
max: 40,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
case "hasUppercase":
|
case 'hasUppercase':
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "passwordInput.uppercaseRequirement",
|
id: 'passwordInput.uppercaseRequirement',
|
||||||
defaultMessage: "{count} uppercase letter",
|
defaultMessage: '{count} uppercase letter',
|
||||||
},
|
},
|
||||||
{ count: 1 }
|
{ count: 1 }
|
||||||
)
|
)
|
||||||
case "hasLowercase":
|
case 'hasLowercase':
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "passwordInput.lowercaseRequirement",
|
id: 'passwordInput.lowercaseRequirement',
|
||||||
defaultMessage: "{count} lowercase letter",
|
defaultMessage: '{count} lowercase letter',
|
||||||
},
|
},
|
||||||
{ count: 1 }
|
{ count: 1 }
|
||||||
)
|
)
|
||||||
case "hasNumber":
|
case 'hasNumber':
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "passwordInput.numberRequirement",
|
id: 'passwordInput.numberRequirement',
|
||||||
defaultMessage: "{count} number",
|
defaultMessage: '{count} number',
|
||||||
},
|
},
|
||||||
{ count: 1 }
|
{ count: 1 }
|
||||||
)
|
)
|
||||||
case "hasSpecialChar":
|
case 'hasSpecialChar':
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: "passwordInput.specialCharacterRequirement",
|
id: 'passwordInput.specialCharacterRequirement',
|
||||||
defaultMessage: "{count} special character",
|
defaultMessage: '{count} special character',
|
||||||
},
|
},
|
||||||
{ count: 1 }
|
{ count: 1 }
|
||||||
)
|
)
|
||||||
case "allowedCharacters":
|
case 'allowedCharacters':
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
id: "passwordInput.allowedCharactersRequirement",
|
id: 'passwordInput.allowedCharactersRequirement',
|
||||||
defaultMessage: "Only allowed characters",
|
defaultMessage: 'Only allowed characters',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.errors} role="status" id="password-requirements">
|
<div
|
||||||
|
className={styles.errors}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="false"
|
||||||
|
id={id}
|
||||||
|
>
|
||||||
{Object.entries(passwordValidators).map(([key, { message }]) => (
|
{Object.entries(passwordValidators).map(([key, { message }]) => (
|
||||||
<Typography variant="Label/xsRegular" key={key}>
|
<Typography variant="Label/xsRegular" key={key}>
|
||||||
<span className={styles.helpText}>
|
<span className={styles.helpText}>
|
||||||
@@ -100,8 +107,8 @@ function Icon({ errorMessage, errors }: IconProps) {
|
|||||||
size={20}
|
size={20}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={intl.formatMessage({
|
aria-label={intl.formatMessage({
|
||||||
id: "common.error",
|
id: 'common.error',
|
||||||
defaultMessage: "Error",
|
defaultMessage: 'Error',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -111,8 +118,8 @@ function Icon({ errorMessage, errors }: IconProps) {
|
|||||||
size={20}
|
size={20}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={intl.formatMessage({
|
aria-label={intl.formatMessage({
|
||||||
id: "common.success",
|
id: 'common.success',
|
||||||
defaultMessage: "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'
|
||||||
@@ -23,11 +23,23 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputWrapper .toggleButton {
|
.toggleButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--Space-x2);
|
right: var(--Space-x2);
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-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.
|
/* Hide the built-in password reveal icon in Microsoft Edge.
|
||||||
@@ -162,6 +162,7 @@
|
|||||||
"./OldDSLink": "./lib/components/OldDSLink/index.tsx",
|
"./OldDSLink": "./lib/components/OldDSLink/index.tsx",
|
||||||
"./OpeningHours": "./lib/components/OpeningHours/index.tsx",
|
"./OpeningHours": "./lib/components/OpeningHours/index.tsx",
|
||||||
"./ParkingInformation": "./lib/components/ParkingInformation/index.tsx",
|
"./ParkingInformation": "./lib/components/ParkingInformation/index.tsx",
|
||||||
|
"./PasswordInput": "./lib/components/PasswordInput/index.tsx",
|
||||||
"./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx",
|
"./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx",
|
||||||
"./PointsRateCard": "./lib/components/RateCard/Points/index.tsx",
|
"./PointsRateCard": "./lib/components/RateCard/Points/index.tsx",
|
||||||
"./Progress": "./lib/components/Progress/index.tsx",
|
"./Progress": "./lib/components/Progress/index.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user