diff --git a/apps/scandic-web/components/Forms/Edit/Profile/FormContent/index.tsx b/apps/scandic-web/components/Forms/Edit/Profile/FormContent/index.tsx index 0d9ab7ea6..916dc4e5c 100644 --- a/apps/scandic-web/components/Forms/Edit/Profile/FormContent/index.tsx +++ b/apps/scandic-web/components/Forms/Edit/Profile/FormContent/index.tsx @@ -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} + /> + - diff --git a/apps/scandic-web/components/Forms/Signup/index.tsx b/apps/scandic-web/components/Forms/Signup/index.tsx index 8f7194271..40dbbe403 100644 --- a/apps/scandic-web/components/Forms/Signup/index.tsx +++ b/apps/scandic-web/components/Forms/Signup/index.tsx @@ -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} /> diff --git a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/index.tsx b/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/index.tsx deleted file mode 100644 index fa291bd6a..000000000 --- a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/index.tsx +++ /dev/null @@ -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 { - 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 ( - { - const errors = isNewPassword - ? Object.values(formState.errors[name]?.types ?? []).flat() - : [] - - return ( - -
- - {visibilityToggleable ? ( - 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} -
- - {isNewPassword ? ( - - ) : null} - - {isNewPassword ? ( - !field.value && fieldState.error ? ( - - ) : null - ) : fieldState.error ? ( - - ) : null} -
- ) - }} - /> - ) -} - -function ErrorMessage({ errorMessage }: { errorMessage?: string }) { - const intl = useIntl() - return ( - - - - ) -} diff --git a/apps/scandic-web/types/components/form/newPassword.ts b/packages/common/utils/zod/newPassword.ts similarity index 100% rename from apps/scandic-web/types/components/form/newPassword.ts rename to packages/common/utils/zod/newPassword.ts diff --git a/packages/design-system/lib/components/Input/Input.tsx b/packages/design-system/lib/components/Input/Input.tsx index 0af149e30..4a89b6a0e 100644 --- a/packages/design-system/lib/components/Input/Input.tsx +++ b/packages/design-system/lib/components/Input/Input.tsx @@ -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( {showClearContentIcon && hasValue && (
- + > + +
)} {rightIcon && !(showClearContentIcon && hasValue) && ( @@ -153,15 +153,15 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent( {showClearContentIcon && hasValue && (
- + > + +
)} {rightIcon && !(showClearContentIcon && hasValue) && ( diff --git a/packages/design-system/lib/components/Input/input.module.css b/packages/design-system/lib/components/Input/input.module.css index 7159eba0a..738ab5cbd 100644 --- a/packages/design-system/lib/components/Input/input.module.css +++ b/packages/design-system/lib/components/Input/input.module.css @@ -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) { diff --git a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/NewPasswordValidation.tsx b/packages/design-system/lib/components/PasswordInput/NewPasswordValidation.tsx similarity index 53% rename from apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/NewPasswordValidation.tsx rename to packages/design-system/lib/components/PasswordInput/NewPasswordValidation.tsx index 781cd59cf..6e4d3d2c1 100644 --- a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/NewPasswordValidation.tsx +++ b/packages/design-system/lib/components/PasswordInput/NewPasswordValidation.tsx @@ -1,19 +1,20 @@ -import { useIntl } from "react-intl" +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 { 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" - -import type { PasswordValidatorKey } from "@/types/components/form/newPassword" +import styles from './passwordInput.module.css' export function NewPasswordValidation({ value, errors, + id, }: { value: string errors: string[] + id: string }) { const intl = useIntl() @@ -21,59 +22,65 @@ export function NewPasswordValidation({ function getErrorMessage(key: PasswordValidatorKey) { switch (key) { - case "length": + case 'length': return intl.formatMessage( { - id: "passwordInput.lengthRequirement", - defaultMessage: "{min} to {max} characters", + id: 'passwordInput.lengthRequirement', + defaultMessage: '{min} to {max} characters', }, { min: 10, max: 40, } ) - case "hasUppercase": + case 'hasUppercase': return intl.formatMessage( { - id: "passwordInput.uppercaseRequirement", - defaultMessage: "{count} uppercase letter", + id: 'passwordInput.uppercaseRequirement', + defaultMessage: '{count} uppercase letter', }, { count: 1 } ) - case "hasLowercase": + case 'hasLowercase': return intl.formatMessage( { - id: "passwordInput.lowercaseRequirement", - defaultMessage: "{count} lowercase letter", + id: 'passwordInput.lowercaseRequirement', + defaultMessage: '{count} lowercase letter', }, { count: 1 } ) - case "hasNumber": + case 'hasNumber': return intl.formatMessage( { - id: "passwordInput.numberRequirement", - defaultMessage: "{count} number", + id: 'passwordInput.numberRequirement', + defaultMessage: '{count} number', }, { count: 1 } ) - case "hasSpecialChar": + case 'hasSpecialChar': return intl.formatMessage( { - id: "passwordInput.specialCharacterRequirement", - defaultMessage: "{count} special character", + id: 'passwordInput.specialCharacterRequirement', + defaultMessage: '{count} special character', }, { count: 1 } ) - case "allowedCharacters": + case 'allowedCharacters': return intl.formatMessage({ - id: "passwordInput.allowedCharactersRequirement", - defaultMessage: "Only allowed characters", + id: 'passwordInput.allowedCharactersRequirement', + defaultMessage: 'Only allowed characters', }) } } return ( -
+
{Object.entries(passwordValidators).map(([key, { message }]) => ( @@ -100,8 +107,8 @@ function Icon({ errorMessage, errors }: IconProps) { size={20} role="img" aria-label={intl.formatMessage({ - id: "common.error", - defaultMessage: "Error", + id: 'common.error', + defaultMessage: 'Error', })} /> ) : ( @@ -111,8 +118,8 @@ function Icon({ errorMessage, errors }: IconProps) { size={20} role="img" aria-label={intl.formatMessage({ - id: "common.success", - defaultMessage: "Success", + id: 'common.success', + defaultMessage: 'Success', })} /> ) diff --git a/packages/design-system/lib/components/PasswordInput/PasswordInput.stories.tsx b/packages/design-system/lib/components/PasswordInput/PasswordInput.stories.tsx new file mode 100644 index 000000000..3a1fd3602 --- /dev/null +++ b/packages/design-system/lib/components/PasswordInput/PasswordInput.stories.tsx @@ -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 + +interface PasswordInputProps { + onSubmit?: (data: PasswordFormData) => void + showErrors?: boolean + defaultNewPassword?: string +} + +function PasswordInputComponent({ + onSubmit, + showErrors = false, + defaultNewPassword = '', +}: PasswordInputProps) { + const methods = useForm({ + 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 ( + +
+ +

Change Password

+
+ + + + + + + + + +
+ ) +} + +const passwordFormMeta: Meta = { + 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 + +export const Default: PasswordFormStory = { + render: (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 + +function PasswordValidationShowcase() { + const methods = useForm({ + 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 ( + +
+
+ +

New Password Validation States

+
+
+
+ +

Empty Password

+
+ +
+ +
+ +

Weak Password (too short)

+
+ +
+ +
+ +

Partial Password (missing requirements)

+
+ +
+ +
+ +

Valid Password

+
+ +
+ +
+ +

Too Long Password

+
+ +
+
+
+
+
+ ) +} + +const showcaseMeta: Meta = { + title: 'Core Components/PasswordInput', + component: PasswordValidationShowcase, + parameters: { + layout: 'fullscreen', + }, +} + +type ShowcaseStory = StoryObj + +export const AllValidationStates: ShowcaseStory = { + render: () => , + parameters: { + ...showcaseMeta.parameters, + }, +} diff --git a/packages/design-system/lib/components/PasswordInput/PasswordInput.test.tsx b/packages/design-system/lib/components/PasswordInput/PasswordInput.test.tsx new file mode 100644 index 000000000..abfe09b9b --- /dev/null +++ b/packages/design-system/lib/components/PasswordInput/PasswordInput.test.tsx @@ -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 +}) { + const methods = useForm({ defaultValues }) + return ( + + +
{children}
+
+
+ ) +} + +const renderPasswordInput = ( + props: React.ComponentProps = {}, + defaultValues?: Record +) => { + return render( + + + + ) +} + +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') + }) + }) +}) diff --git a/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx b/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx new file mode 100644 index 000000000..80a75e27d --- /dev/null +++ b/packages/design-system/lib/components/PasswordInput/PasswordInput.tsx @@ -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 { + 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 ( + { + 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 ( + +
+ + {visibilityToggleable ? ( + + ) : null} +
+ + {showRequirements ? ( + + ) : null} + + {hasError && (!isNewPassword || !field.value) ? ( + + ) : null} +
+ ) + }} + /> + ) +} + +function ErrorMessage({ + errorMessage, + formatErrorMessage, + id, +}: { + errorMessage?: string + formatErrorMessage: (intl: IntlShape, errorMessage?: string) => string + id: string +}) { + const intl = useIntl() + return ( + + + + ) +} diff --git a/packages/design-system/lib/components/PasswordInput/index.tsx b/packages/design-system/lib/components/PasswordInput/index.tsx new file mode 100644 index 000000000..30ca48acc --- /dev/null +++ b/packages/design-system/lib/components/PasswordInput/index.tsx @@ -0,0 +1 @@ +export { PasswordInput } from './PasswordInput' diff --git a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/passwordInput.module.css b/packages/design-system/lib/components/PasswordInput/passwordInput.module.css similarity index 73% rename from apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/passwordInput.module.css rename to packages/design-system/lib/components/PasswordInput/passwordInput.module.css index 89488e0f6..72ca83bd9 100644 --- a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/passwordInput.module.css +++ b/packages/design-system/lib/components/PasswordInput/passwordInput.module.css @@ -23,11 +23,23 @@ position: relative; } -.inputWrapper .toggleButton { +.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. diff --git a/packages/design-system/package.json b/packages/design-system/package.json index f05181a3c..f482350ae 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -162,6 +162,7 @@ "./OldDSLink": "./lib/components/OldDSLink/index.tsx", "./OpeningHours": "./lib/components/OpeningHours/index.tsx", "./ParkingInformation": "./lib/components/ParkingInformation/index.tsx", + "./PasswordInput": "./lib/components/PasswordInput/index.tsx", "./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx", "./PointsRateCard": "./lib/components/RateCard/Points/index.tsx", "./Progress": "./lib/components/Progress/index.tsx",