diff --git a/packages/form/package.json b/packages/form/package.json index f67002037d..6310c7e2f2 100644 --- a/packages/form/package.json +++ b/packages/form/package.json @@ -55,6 +55,7 @@ "@ultraviolet/icons": "workspace:*", "@ultraviolet/themes": "workspace:*", "@ultraviolet/ui": "workspace:*", + "awesome-phonenumber": "7.8.0", "react-hook-form": "7.72.0" }, "devDependencies": { diff --git a/packages/form/src/components/PhoneInputField/__stories__/DefaultCountry.stories.tsx b/packages/form/src/components/PhoneInputField/__stories__/DefaultCountry.stories.tsx new file mode 100644 index 0000000000..a67ca88412 --- /dev/null +++ b/packages/form/src/components/PhoneInputField/__stories__/DefaultCountry.stories.tsx @@ -0,0 +1,10 @@ +import { Template } from './Template.stories' + +export const WithDefaultCountry = Template.bind({}) + +WithDefaultCountry.args = { + ...Template.args, + defaultCountry: 'US', + label: 'US Phone Number', + name: 'usPhone', +} diff --git a/packages/form/src/components/PhoneInputField/__stories__/Error.stories.tsx b/packages/form/src/components/PhoneInputField/__stories__/Error.stories.tsx new file mode 100644 index 0000000000..4262924982 --- /dev/null +++ b/packages/form/src/components/PhoneInputField/__stories__/Error.stories.tsx @@ -0,0 +1,26 @@ +import { PhoneField } from '..' +import { Form } from '../..' +import { useForm } from '../../..' +import { mockErrors } from '../../../mocks' + +import type { StoryFn } from '@storybook/react-vite' + +export const WithError: StoryFn = () => { + const methods = useForm({ + defaultValues: { + phone: 'invalid', + }, + }) + + return ( +
{}}> + + + ) +} diff --git a/packages/form/src/components/PhoneInputField/__stories__/Playground.stories.tsx b/packages/form/src/components/PhoneInputField/__stories__/Playground.stories.tsx new file mode 100644 index 0000000000..6facc9504c --- /dev/null +++ b/packages/form/src/components/PhoneInputField/__stories__/Playground.stories.tsx @@ -0,0 +1,9 @@ +import { Template } from './Template.stories' + +export const Playground = Template.bind({}) + +Playground.args = { + label: 'Phone Number', + name: 'phone', + placeholder: 'Enter your phone number', +} diff --git a/packages/form/src/components/PhoneInputField/__stories__/Required.stories.tsx b/packages/form/src/components/PhoneInputField/__stories__/Required.stories.tsx new file mode 100644 index 0000000000..5b3df0b189 --- /dev/null +++ b/packages/form/src/components/PhoneInputField/__stories__/Required.stories.tsx @@ -0,0 +1,10 @@ +import { Template } from './Template.stories' + +export const Required = Template.bind({}) + +Required.args = { + ...Template.args, + label: 'Phone Number', + name: 'phone', + required: true, +} diff --git a/packages/form/src/components/PhoneInputField/__stories__/Template.stories.tsx b/packages/form/src/components/PhoneInputField/__stories__/Template.stories.tsx new file mode 100644 index 0000000000..bc7853ecb1 --- /dev/null +++ b/packages/form/src/components/PhoneInputField/__stories__/Template.stories.tsx @@ -0,0 +1,22 @@ +import { PhoneField } from '..' +import { Form } from '../..' +import { useForm } from '../../..' +import { mockErrors } from '../../../mocks' + +import type { StoryFn } from '@storybook/react-vite' + +export const Template: StoryFn = ({ ...args }) => ( +
{}}> + + +) + +Template.args = { + label: 'Phone Number', + name: 'phone', + placeholder: 'Enter your phone number', + defaultCountry: 'FR', +} diff --git a/packages/form/src/components/PhoneInputField/__stories__/index.stories.tsx b/packages/form/src/components/PhoneInputField/__stories__/index.stories.tsx new file mode 100644 index 0000000000..3689835ed1 --- /dev/null +++ b/packages/form/src/components/PhoneInputField/__stories__/index.stories.tsx @@ -0,0 +1,76 @@ +import { Snippet, Stack, Text } from '@ultraviolet/ui' + +import { PhoneField } from '..' +import { Form } from '../..' +import { useForm } from '../../..' +import { mockErrors } from '../../../mocks' + +import type { Meta } from '@storybook/react-vite' + +export default { + component: PhoneField, + decorators: [ + ChildStory => { + const methods = useForm() + const { + errors, + isDirty, + isSubmitting, + touchedFields, + submitCount, + dirtyFields, + isValid, + isLoading, + isSubmitted, + isValidating, + isSubmitSuccessful, + } = methods.formState + + return ( +
{}}> + + + + + Form input values: + + + {JSON.stringify(methods.watch(), null, 1)} + + + + + Form values: + + + {JSON.stringify( + { + errors, + isDirty, + isSubmitting, + touchedFields, + submitCount, + dirtyFields, + isValid, + isLoading, + isSubmitted, + isValidating, + isSubmitSuccessful, + }, + null, + 1, + )} + + + +
+ ) + }, + ], + title: 'Form/Components/Fields/PhoneField', +} as Meta + +export { Playground } from './Playground.stories' +export { Required } from './Required.stories' +export { WithDefaultCountry } from './DefaultCountry.stories' +export { WithError } from './Error.stories' diff --git a/packages/form/src/components/PhoneInputField/__tests__/index.test.tsx b/packages/form/src/components/PhoneInputField/__tests__/index.test.tsx new file mode 100644 index 0000000000..bd8c136ef4 --- /dev/null +++ b/packages/form/src/components/PhoneInputField/__tests__/index.test.tsx @@ -0,0 +1,129 @@ +import { renderHook, screen, waitFor } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { mockFormErrors, renderWithForm } from '@utils/test' +import { useForm } from 'react-hook-form' +import { describe, expect, test, vi } from 'vitest' + +import { PhoneField } from '..' +import { Submit } from '../..' +import { Form } from '../../Form' + +describe('form - PhoneField', () => { + test('should render correctly', () => { + const { asFragment } = renderWithForm( + , + ) + expect(asFragment()).toMatchSnapshot() + }) + + test('should validate phone number', async () => { + const onSubmit = vi.fn() + const { result } = renderHook(() => useForm<{ phone: string }>()) + + renderWithForm( +
+ + Submit + , + ) + + await userEvent.click(screen.getByText('Submit')) + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(0) + }) + + const phoneInput = screen.getByRole('textbox') + await userEvent.type(phoneInput, '+33612345678') + await userEvent.click(screen.getByText('Submit')) + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledOnce() + }) + }) + + test('should work with default country', async () => { + const onSubmit = vi.fn() + const { result } = renderHook(() => useForm<{ phone: string }>()) + + const { asFragment } = renderWithForm( +
+ + Submit + , + ) + + const phoneInput = screen.getByRole('textbox') + await userEvent.type(phoneInput, '+12025551234') + await userEvent.click(screen.getByText('Submit')) + await waitFor(() => { + expect(onSubmit.mock.calls[0][0]).toEqual({ + phone: '+12025551234', + }) + }) + expect(asFragment()).toMatchSnapshot() + }) + + test('should show error for invalid phone number', async () => { + const onSubmit = vi.fn() + const { result } = renderHook(() => useForm<{ phone: string }>()) + + renderWithForm( +
+ + Submit + , + ) + + const phoneInput = screen.getByRole('textbox') + await userEvent.type(phoneInput, 'invalid') + await userEvent.click(screen.getByText('Submit')) + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(0) + }) + expect( + screen.getByText("This doesn't appear to be a valid phone number."), + ).toBeDefined() + }) + + test('should be disabled when disabled prop is true', () => { + const { asFragment } = renderWithForm( + , + ) + expect(asFragment()).toMatchSnapshot() + }) +}) diff --git a/packages/form/src/components/PhoneInputField/index.tsx b/packages/form/src/components/PhoneInputField/index.tsx new file mode 100644 index 0000000000..f6ba21c95b --- /dev/null +++ b/packages/form/src/components/PhoneInputField/index.tsx @@ -0,0 +1,99 @@ +'use client' + +import { PhoneInput } from '@ultraviolet/ui' +import { parsePhoneNumber } from 'awesome-phonenumber' +import { useController } from 'react-hook-form' + +import { useErrors } from '../../providers' + +import type { BaseFieldProps, FieldPath, FieldValues } from '@ultraviolet/form' +import type { ComponentProps } from 'react' + +type PhoneInputValue = NonNullable['value']> + +type PhoneFieldProps< + TFieldValues extends FieldValues, + TName extends FieldPath, +> = BaseFieldProps & + ComponentProps & { + /** + * message show to the user when the number is not a correct phone number + */ + parseNumberErrorMessage: string + } + +export const PhoneField = < + TFieldValues extends FieldValues, + TName extends FieldPath = FieldPath, +>({ + className, + disabled, + id, + label, + name, + onBlur, + onChange, + onFocus, + required, + defaultCountry, + placeholder, + 'data-testid': dataTestId, + parseNumberErrorMessage = "This doesn't appear to be a valid phone number.", + onParsingError, +}: PhoneFieldProps) => { + const { getError } = useErrors() + const { + field, + fieldState: { error: fieldError }, + } = useController({ + name, + rules: { + required, + validate: (phoneNumber: PhoneInputValue) => { + try { + return !!phoneNumber && !parsePhoneNumber(phoneNumber).valid + ? parseNumberErrorMessage + : undefined + } catch (error: unknown) { + if (error instanceof Error) { + onParsingError?.({ + error, + inputValue: phoneNumber, + }) + } + + return phoneNumber + } + }, + }, + }) + + return ( + { + field.onBlur() + onBlur?.(event) + }} + onChange={event => { + field.onChange(event) + onChange?.(event) + }} + onFocus={event => { + onFocus?.(event) + }} + onParsingError={onParsingError} + placeholder={placeholder} + ref={field.ref} + required={required} + value={field.value} + /> + ) +} diff --git a/packages/form/src/components/index.ts b/packages/form/src/components/index.ts index c80e2ad48a..028fe5156a 100644 --- a/packages/form/src/components/index.ts +++ b/packages/form/src/components/index.ts @@ -5,6 +5,7 @@ export { FileInputField } from './FileInputField' export { Form } from './Form' export { KeyValueField } from './KeyValueField' export { NumberInputField } from './NumberInputField' +export { PhoneField } from './PhoneInputField' export { RadioField } from './RadioField' export { RadioGroupField } from './RadioGroupField' export { SelectableCardField } from './SelectableCardField' diff --git a/packages/ui/package.json b/packages/ui/package.json index 8cd9c9a696..b6cdbff367 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -86,6 +86,7 @@ "@vanilla-extract/dynamic": "2.1.5", "@vanilla-extract/recipes": "0.5.7", "@vanilla-extract/sprinkles": "1.6.5", + "awesome-phonenumber": "7.8.0", "codemirror": "6.0.2", "csstype": "3.2.3", "deepmerge": "4.3.1", diff --git a/packages/ui/src/components/PhoneInput/__stories__/ControlledVSUncontrolled.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/ControlledVSUncontrolled.stories.tsx new file mode 100644 index 0000000000..c3c3c772d7 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/ControlledVSUncontrolled.stories.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' + +import { PhoneInput } from '..' +import { Stack } from '../../Stack' +import { Text } from '../../Text' + +import type { StoryFn } from '@storybook/react-vite' + +export const ControlledVSUncontrolled: StoryFn = props => { + const [value, setValue] = useState('+33612345678') + + return ( + + + + setValue(event.target.value)} + value={value} + {...props} + /> + + Current value:{' '} + + {value} + + + + + ) +} + +ControlledVSUncontrolled.parameters = { + docs: { + description: { + story: + 'The component can be controlled or uncontrolled.\n\n The difference is that in the controlled version, the `value` and `onChange` is passed as a prop and the component does not manage its own state.\n\n In the uncontrolled version, the component manages its own state. For more information check [React documentation](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components).', + }, + }, +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/Default.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/Default.stories.tsx new file mode 100644 index 0000000000..acb6c1c8ed --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/Default.stories.tsx @@ -0,0 +1,10 @@ +import { Template } from './Template.stories' + +export const Default = Template.bind({}) + +Default.args = { + defaultCountry: 'US', + label: 'Phone Number', + name: 'phone', + placeholder: 'Enter phone number', +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/Disabled.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/Disabled.stories.tsx new file mode 100644 index 0000000000..850ccb18a5 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/Disabled.stories.tsx @@ -0,0 +1,9 @@ +import { Template } from './Template.stories' + +export const Disabled = Template.bind({}) + +Disabled.args = { + ...Template.args, + disabled: true, + value: '+33612345678', +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/Error.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/Error.stories.tsx new file mode 100644 index 0000000000..595a5d6a25 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/Error.stories.tsx @@ -0,0 +1,8 @@ +import { Template } from './Template.stories' + +export const WithError = Template.bind({}) + +WithError.args = { + ...Template.args, + error: 'Invalid phone number format', +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/Playground.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/Playground.stories.tsx new file mode 100644 index 0000000000..5fd6bef5ce --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/Playground.stories.tsx @@ -0,0 +1,5 @@ +import { Template } from './Template.stories' + +export const Playground = Template.bind({}) + +Playground.args = Template.args diff --git a/packages/ui/src/components/PhoneInput/__stories__/PrefilledNumber.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/PrefilledNumber.stories.tsx new file mode 100644 index 0000000000..3c6c7336f6 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/PrefilledNumber.stories.tsx @@ -0,0 +1,8 @@ +import { Template } from './Template.stories' + +export const PrefilledNumber = Template.bind({}) + +PrefilledNumber.args = { + ...Template.args, + value: '+33607080910', +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/Required.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/Required.stories.tsx new file mode 100644 index 0000000000..509367b4ad --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/Required.stories.tsx @@ -0,0 +1,8 @@ +import { Template } from './Template.stories' + +export const Required = Template.bind({}) + +Required.args = { + ...Template.args, + required: true, +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/Template.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/Template.stories.tsx new file mode 100644 index 0000000000..f0e4dec695 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/Template.stories.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react' + +import { PhoneInput } from '..' + +import type { StoryFn } from '@storybook/react-vite' + +export const Template: StoryFn = ({ ...args }) => { + const [value, setValue] = useState(args.value) + + return ( + setValue(event.target.value)} + value={value} + /> + ) +} + +Template.args = { + defaultCountry: 'FR', + label: 'Phone Number', + name: 'phone', + placeholder: 'Enter phone number', + value: '+33612345678', +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/UserControl.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/UserControl.stories.tsx new file mode 100644 index 0000000000..ec9bfcee4f --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/UserControl.stories.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' + +import { PhoneInput } from '..' +import { parsePhoneValue } from '../helpers' + +import type { StoryFn } from '@storybook/react-vite' + +export const WithUserControlledFormatting: StoryFn = () => { + const [value, setValue] = useState('') + const [parsedData, setParsedData] = useState('') + + const handleChange = (event: React.ChangeEvent) => { + const inputValue = event.target.value + setValue(inputValue) + + const parsed = parsePhoneValue(inputValue, 'FR') + setParsedData(JSON.stringify(parsed, null, 2)) + } + + return ( +
+ +
+        {parsedData || 'Start typing to see parsed data...'}
+      
+
+ ) +} + +export const WithOnValueChange: StoryFn = () => { + const [value, setValue] = useState('') + const [metadata, setMetadata] = useState('') + + return ( +
+ { + setMetadata(JSON.stringify(data, null, 2)) + }} + onChange={event => setValue(event.target.value)} + placeholder="Enter phone number" + value={value} + /> +
+        {metadata || 'Start typing to see metadata...'}
+      
+
+ ) +} + +export const WithExternalValidation: StoryFn = () => { + const [value, setValue] = useState('') + const [error, setError] = useState('') + + const handleChange = (event: React.ChangeEvent) => { + const inputValue = event.target.value + setValue(inputValue) + + const parsed = parsePhoneValue(inputValue, 'FR') + if (inputValue.length > 0 && !parsed.valid) { + setError('Invalid phone number format') + } else { + setError('') + } + } + + return ( + + ) +} diff --git a/packages/ui/src/components/PhoneInput/__stories__/index.stories.tsx b/packages/ui/src/components/PhoneInput/__stories__/index.stories.tsx new file mode 100644 index 0000000000..6be5262f00 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__stories__/index.stories.tsx @@ -0,0 +1,19 @@ +import { PhoneInput } from '..' + +import type { Meta } from '@storybook/react-vite' + +export default { + component: PhoneInput, + title: 'Components/Data Entry/PhoneInput', +} as Meta + +export { Playground } from './Playground.stories' +export { Default } from './Default.stories' +export { PrefilledNumber } from './PrefilledNumber.stories' +export { Disabled } from './Disabled.stories' +export { WithError } from './Error.stories' +export { Required } from './Required.stories' +export { ControlledVSUncontrolled } from './ControlledVSUncontrolled.stories' +export { WithUserControlledFormatting } from './UserControl.stories' +export { WithOnValueChange } from './UserControl.stories' +export { WithExternalValidation } from './UserControl.stories' diff --git a/packages/ui/src/components/PhoneInput/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/PhoneInput/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..eb4bbd45ee --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,197 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ui - PhoneInput > should render correctly when input is disabled 1`] = ` + +
+
+ +
+

+

+
+
+
+`; + +exports[`ui - PhoneInput > should render correctly with basic props 1`] = ` + +
+
+ +
+

+

+
+
+
+`; + +exports[`ui - PhoneInput > should render correctly with error 1`] = ` + +
+
+ +
+

+ Invalid phone number +

+
+
+
+
+`; + +exports[`ui - PhoneInput > should render correctly with required field 1`] = ` + +
+
+ +
+

+

+
+
+
+`; diff --git a/packages/ui/src/components/PhoneInput/__tests__/helpers.test.tsx b/packages/ui/src/components/PhoneInput/__tests__/helpers.test.tsx new file mode 100644 index 0000000000..678cf34ea9 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__tests__/helpers.test.tsx @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'vitest' + +import { + formatPhoneNumber, + parsePhoneValue, + validatePhoneNumber, +} from '../helpers' + +describe('phoneInput helpers', () => { + describe(parsePhoneValue, () => { + test('should parse valid French phone number', () => { + const result = parsePhoneValue('+33612345678', 'FR') + + expect(result.valid).toBeTruthy() + expect(result.country).toBe('FR') + expect(result.e164).toBe('+33612345678') + expect(result.international).toBe('+33 6 12 34 56 78') + expect(result.national).toBe('06 12 34 56 78') + }) + + test('should parse US phone number', () => { + const result = parsePhoneValue('+14155551234', 'US') + + expect(result.valid).toBeTruthy() + expect(result.country).toBe('US') + expect(result.e164).toBe('+14155551234') + }) + + test('should handle invalid phone number', () => { + const result = parsePhoneValue('invalid', 'FR') + + expect(result.valid).toBeFalsy() + expect(result.country).toBe('FR') + expect(result.e164).toBeNull() + expect(result.formatted).toBe('invalid') + }) + + test('should handle 10-digit French number without country code', () => { + const result = parsePhoneValue('0612345678', 'FR') + + expect(result.valid).toBeTruthy() + expect(result.country).toBe('FR') + }) + + test('should return input value when parsing fails', () => { + const result = parsePhoneValue('', 'FR') + + expect(result.valid).toBeFalsy() + expect(result.formatted).toBe('') + }) + }) + + describe(formatPhoneNumber, () => { + test('should format phone number in international format', () => { + const result = formatPhoneNumber('+33612345678', { + format: 'international', + }) + + expect(result).toBe('+33 6 12 34 56 78') + }) + + test('should format phone number in national format', () => { + const result = formatPhoneNumber('+33612345678', { + format: 'national', + }) + + expect(result).toBe('06 12 34 56 78') + }) + + test('should format phone number in e164 format', () => { + const result = formatPhoneNumber('+33612345678', { + format: 'e164', + }) + + expect(result).toBe('+33612345678') + }) + + test('should default to international format', () => { + const result = formatPhoneNumber('+33612345678') + + expect(result).toBe('+33 6 12 34 56 78') + }) + + test('should return original value if formatting fails', () => { + const result = formatPhoneNumber('invalid') + + expect(result).toBe('invalid') + }) + }) + + describe(validatePhoneNumber, () => { + test('should validate correct French phone number', () => { + const result = validatePhoneNumber('+33612345678', { regionCode: 'FR' }) + + expect(result).toBeTruthy() + }) + + test('should validate correct US phone number', () => { + const result = validatePhoneNumber('+14155551234', { regionCode: 'US' }) + + expect(result).toBeTruthy() + }) + + test('should reject invalid phone number', () => { + const result = validatePhoneNumber('invalid') + + expect(result).toBeFalsy() + }) + + test('should reject incomplete phone number', () => { + const result = validatePhoneNumber('+336') + + expect(result).toBeFalsy() + }) + + test('should validate without region code', () => { + const result = validatePhoneNumber('+33612345678') + + expect(result).toBeTruthy() + }) + }) +}) diff --git a/packages/ui/src/components/PhoneInput/__tests__/index.test.tsx b/packages/ui/src/components/PhoneInput/__tests__/index.test.tsx new file mode 100644 index 0000000000..46e5c3c7d5 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/__tests__/index.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { renderWithTheme, shouldMatchSnapshot } from '@utils/test' +import { describe, expect, test, vi } from 'vitest' + +import { PhoneInput } from '..' + +describe('ui - PhoneInput', () => { + test('should render correctly with basic props', () => + shouldMatchSnapshot( + {}} + value="+33612345678" + />, + )) + + test('should control the value', () => { + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.value).toBe('+33612345678') + fireEvent.change(input, { target: { value: '+33678901234' } }) + expect(onChange).toHaveBeenCalled() + }) + + test('should render correctly when input is disabled', () => + shouldMatchSnapshot( + {}} + value="+33612345678" + />, + )) + + test('should render correctly with error', () => + shouldMatchSnapshot( + {}} + value="+33612345678" + />, + )) + + test('should display error message', () => { + const onChange = vi.fn() + const errorMessage = 'Invalid phone number format' + + renderWithTheme( + , + ) + + expect(screen.getByText(errorMessage)).toBeDefined() + }) + + test('should render correctly with required field', () => + shouldMatchSnapshot( + {}} + required + value="+33612345678" + />, + )) + + test('should display asterisk for required field', () => { + const onChange = vi.fn() + + renderWithTheme( + , + ) + + expect(screen.getByText('*')).toBeDefined() + }) + + test('should use default country', () => { + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByPlaceholderText('Enter phone number') + expect(input).toBeDefined() + }) + + test('should call onFocus and onBlur handlers', () => { + const onFocus = vi.fn() + const onBlur = vi.fn() + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByRole('textbox') + fireEvent.focus(input) + expect(onFocus).toHaveBeenCalled() + fireEvent.blur(input) + expect(onBlur).toHaveBeenCalled() + }) + + test('should have correct type attribute', () => { + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.type).toBe('tel') + }) + + test('should respect maxLength attribute', () => { + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.maxLength).toBe(20) + }) + + test('should format number on change', async () => { + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByPlaceholderText('Enter phone number') + await userEvent.type(input, '0612345678') + expect(onChange).toHaveBeenCalled() + }) + + test('should respect disableAutoFormat prop', async () => { + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByPlaceholderText('Enter phone number') + await userEvent.type(input, '0612345678') + expect(input).toHaveValue('0612345678') + }) + + test('should call onValueChange with parsed metadata', async () => { + const onValueChange = vi.fn() + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByPlaceholderText('Enter phone number') + await userEvent.type(input, '0612345678') + + expect(onValueChange).toHaveBeenCalled() + const callArg = onValueChange.mock.calls[0]?.[0] + expect(callArg).toHaveProperty('inputValue') + expect(callArg).toHaveProperty('formatted') + expect(callArg).toHaveProperty('country') + expect(callArg).toHaveProperty('valid') + expect(callArg).toHaveProperty('e164') + expect(callArg).toHaveProperty('international') + }) + + test('should call onValueChange with metadata', async () => { + const onValueChange = vi.fn() + const onChange = vi.fn() + + renderWithTheme( + , + ) + + const input = screen.getByPlaceholderText('Enter phone number') + await userEvent.type(input, '0612345678') + + expect(onValueChange).toHaveBeenCalled() + const callArg = onValueChange.mock.calls[0]?.[0] + expect(callArg).toHaveProperty('inputValue') + expect(callArg).toHaveProperty('country') + }) +}) diff --git a/packages/ui/src/components/PhoneInput/helpers.ts b/packages/ui/src/components/PhoneInput/helpers.ts new file mode 100644 index 0000000000..e2d548222c --- /dev/null +++ b/packages/ui/src/components/PhoneInput/helpers.ts @@ -0,0 +1,115 @@ +import { parsePhoneNumber } from 'awesome-phonenumber' + +const startingCharFlagsHexValue = 0x1_f1_e6 + +const getRegionalIndicatorSymbol = (letter: string) => + String.fromCodePoint( + startingCharFlagsHexValue - 65 + letter.toUpperCase().charCodeAt(0), + ) + +export const getCountryFlag = (country: string) => + getRegionalIndicatorSymbol(country[0] ?? '') + + getRegionalIndicatorSymbol(country[1] ?? '') + +export type ParsedPhoneNumber = { + inputValue: string + formatted: string + country: string | null + valid: boolean + e164: string | null + international: string | null + national: string | null +} + +export const parsePhoneValue = ( + inputValue: string, + defaultCountry = 'FR', +): ParsedPhoneNumber => { + try { + const parsed = parsePhoneNumber(inputValue) + const country = parsed.regionCode + let phoneNumber = inputValue + + if (!country && phoneNumber.length === 10) { + phoneNumber = `+33${phoneNumber}` + } + + const isValid = + phoneNumber.length > 4 && + parsePhoneNumber(phoneNumber, { regionCode: country || defaultCountry }) + .valid + + const parsedValid = parsePhoneNumber(phoneNumber, { + regionCode: country || defaultCountry, + }) + + return { + inputValue, + formatted: isValid + ? (parsedValid.number?.international ?? inputValue) + : inputValue, + country: country || defaultCountry, + valid: isValid, + e164: isValid ? (parsedValid.number?.e164 ?? null) : null, + international: isValid + ? (parsedValid.number?.international ?? null) + : null, + national: isValid ? (parsedValid.number?.national ?? null) : null, + } + } catch { + return { + inputValue, + formatted: inputValue, + country: null, + valid: false, + e164: null, + international: null, + national: null, + } + } +} + +export const formatPhoneNumber = ( + phoneNumber: string, + options?: { + regionCode?: string + format?: 'international' | 'national' | 'e164' | 'rfc3966' + }, +): string => { + try { + const parsed = parsePhoneNumber(phoneNumber, options) + const format = options?.format || 'international' + + switch (format) { + case 'e164': { + return parsed.number?.e164 ?? phoneNumber + } + case 'national': { + return parsed.number?.national ?? phoneNumber + } + case 'rfc3966': { + return parsed.number?.rfc3966 ?? phoneNumber + } + case 'international': { + return parsed.number?.international ?? phoneNumber + } + default: { + return parsed.number?.international ?? phoneNumber + } + } + } catch { + return phoneNumber + } +} + +export const validatePhoneNumber = ( + phoneNumber: string, + options?: { regionCode?: string }, +): boolean => { + try { + const parsed = parsePhoneNumber(phoneNumber, options) + return parsed.valid || false + } catch { + return false + } +} diff --git a/packages/ui/src/components/PhoneInput/index.tsx b/packages/ui/src/components/PhoneInput/index.tsx new file mode 100644 index 0000000000..e6149075f0 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/index.tsx @@ -0,0 +1,211 @@ +'use client' + +import { cn } from '@ultraviolet/utils' +import { getExample, parsePhoneNumber } from 'awesome-phonenumber' +import { useImperativeHandle, useRef, useState } from 'react' + +import { Expandable } from '../Expandable' +import { Text } from '../Text' + +import { getCountryFlag } from './helpers' +import { phoneInputStyle } from './styles.css' + +import type { + ChangeEvent, + ComponentType, + InputHTMLAttributes, + Ref, +} from 'react' + +type PhoneInputLabelProps = { + disabled?: boolean + error?: string + ref?: Ref +} + +type InputProps = Partial< + Pick< + InputHTMLAttributes, + 'id' | 'placeholder' | 'onBlur' | 'onFocus' + > +> & { + name: string + 'data-testid'?: string | null +} + +type PhoneInputProps = PhoneInputLabelProps & { + value?: string + label?: string + required?: boolean + className?: string + defaultCountry?: string + onChange?: (event: ChangeEvent) => void + error?: string + onParsingError?: ({ + error, + inputValue, + }: { + error: Error + inputValue: string + }) => void + disableAutoFormat?: boolean + onValueChange?: (value: { + inputValue: string + formatted: string + country: string | null + valid: boolean + e164: string | null + international: string | null + }) => void +} & InputProps + +type PhoneInputType = ComponentType + +export const PhoneInput: PhoneInputType = ({ + disabled = false, + name, + id, + placeholder, + defaultCountry = 'FR', + 'data-testid': dataTestId, + onBlur, + onChange, + onFocus, + error: customError, + required, + value, + className, + label = 'Phone', + onParsingError, + ref, + disableAutoFormat = false, + onValueChange, +}) => { + const inputRef = useRef(null) + + useImperativeHandle(ref, () => inputRef.current!, [inputRef]) + + const [countryFlag, setCountryFlag] = useState(defaultCountry) + + const formatNumber = ({ inputValue }: { inputValue: string }) => { + try { + const parsed = parsePhoneNumber(inputValue) + const country = parsed.regionCode + let phoneNumber = inputValue + + if (!country) { + setCountryFlag(defaultCountry) + if (phoneNumber.length === 10) { + phoneNumber = `+33${phoneNumber}` + } + } else { + setCountryFlag(country) + } + + const isValid = + phoneNumber.length > 4 && + parsePhoneNumber(phoneNumber, { regionCode: countryFlag }).valid + + const formattedNumber = isValid + ? parsePhoneNumber(phoneNumber, { regionCode: countryFlag }).number + ?.international + : phoneNumber + + const e164 = isValid + ? parsePhoneNumber(phoneNumber, { regionCode: countryFlag }).number + ?.e164 + : null + + const result = { + inputValue, + formatted: formattedNumber ?? inputValue, + country: country ?? countryFlag, + valid: isValid, + e164: e164 ?? null, + international: formattedNumber ?? null, + } + + onValueChange?.(result) + + if (disableAutoFormat) { + return inputValue + } + + return formattedNumber + } catch (error: unknown) { + if (error instanceof Error) { + onParsingError?.({ + error, + inputValue, + }) + } + + return inputValue + } + } + + const localId = id || `phone-${name}` + + return ( +
+ + + + {customError} + + +
+ ) +} diff --git a/packages/ui/src/components/PhoneInput/styles.css.ts b/packages/ui/src/components/PhoneInput/styles.css.ts new file mode 100644 index 0000000000..7b626b6b25 --- /dev/null +++ b/packages/ui/src/components/PhoneInput/styles.css.ts @@ -0,0 +1,77 @@ +import { theme } from '@ultraviolet/themes' +import { style } from '@vanilla-extract/css' + +const flag = style({ + maxWidth: '64px', +}) + +const text = style({ + marginLeft: theme.space['1'], +}) + +const span = style({ + position: 'absolute', + top: '-12px', + left: '12px', + fontSize: '13px', + padding: `0 ${theme.space['2']}`, + backgroundColor: theme.colors.other.elevation.background.overlay, +}) + +const label = style({ + height: '48px', + position: 'relative', + display: 'flex', + borderRadius: '4px', + alignItems: 'center', + border: `1px solid ${theme.colors.neutral.borderWeak}`, + padding: `${theme.space['1']} ${theme.space['1']}`, + gap: theme.space['1'], + selectors: { + '&[data-disabled="true"]': { + color: theme.colors.neutral.textDisabled, + borderColor: theme.colors.neutral.borderDisabled, + cursor: 'not-allowed', + }, + '&[data-error="true"]': { + borderColor: theme.colors.danger.border, + color: theme.colors.danger.text, + }, + '&:focus-within': { + border: `1px solid ${theme.colors.primary.border}`, + boxShadow: theme.shadows.focusPrimary, + }, + }, +}) + +const error = style({ + paddingTop: theme.space['0.25'], +}) + +const input = style({ + height: '100%', + border: 'none', + outline: 'none', + width: '100%', + background: theme.colors.neutral.background, + color: theme.colors.neutral.text, + selectors: { + '&::placeholder': { + color: theme.colors.neutral.textWeak, + }, + '&:disabled': { + cursor: 'not-allowed', + pointerEvents: 'none', + backgroundColor: 'inherit', + }, + }, +}) + +export const phoneInputStyle = { + flag, + text, + span, + label, + error, + input, +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index d202f73be4..0ba0b77253 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -28,6 +28,7 @@ export { Label } from './Label' export { LineChart } from './LineChart' export { Link } from './Link' export { List } from './List' +export { PhoneInput } from './PhoneInput' export { Loader } from './Loader' export { Menu } from './Menu' export { Meter } from './Meter' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f326f461ad..d3f1dfa773 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,9 @@ importers: '@ultraviolet/ui': specifier: workspace:* version: link:../ui + awesome-phonenumber: + specifier: 7.8.0 + version: 7.8.0 react-hook-form: specifier: 7.72.0 version: 7.72.0(react@19.2.4) @@ -768,6 +771,9 @@ importers: '@vanilla-extract/sprinkles': specifier: 1.6.5 version: 1.6.5(@vanilla-extract/css@1.18.0) + awesome-phonenumber: + specifier: 7.8.0 + version: 7.8.0 codemirror: specifier: 6.0.2 version: 6.0.2 @@ -4095,6 +4101,10 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + awesome-phonenumber@7.8.0: + resolution: {integrity: sha512-zw23nvKt6gLGgCrKZ5Z7ZK0lm3k39/uZTw+cWp5tpiXVfEFSt9AEVFDzSycws76G64xOMrIVp2upYvJxwRgzvw==} + engines: {node: '>=18'} + axe-core@4.10.2: resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==} engines: {node: '>=4'} @@ -10848,6 +10858,8 @@ snapshots: at-least-node@1.0.0: {} + awesome-phonenumber@7.8.0: {} + axe-core@4.10.2: {} axe-core@4.11.1: {}