Skip to content

Commit cbe4622

Browse files
committed
feat(component): add phone input
1 parent 5fa7594 commit cbe4622

26 files changed

Lines changed: 1165 additions & 1 deletion

packages/form/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@ultraviolet/icons": "workspace:*",
5656
"@ultraviolet/themes": "workspace:*",
5757
"@ultraviolet/ui": "workspace:*",
58+
"awesome-phonenumber": "7.8.0",
5859
"react-hook-form": "7.72.0"
5960
},
6061
"devDependencies": {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Template } from './Template.stories'
2+
3+
export const WithDefaultCountry = Template.bind({})
4+
5+
WithDefaultCountry.args = {
6+
...Template.args,
7+
defaultCountry: 'US',
8+
label: 'US Phone Number',
9+
name: 'usPhone',
10+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { PhoneField } from '..'
2+
import { Form } from '../..'
3+
import { useForm } from '../../..'
4+
import { mockErrors } from '../../../mocks'
5+
6+
import type { StoryFn } from '@storybook/react-vite'
7+
8+
export const WithError: StoryFn<typeof PhoneField> = () => {
9+
const methods = useForm({
10+
defaultValues: {
11+
phone: 'invalid',
12+
},
13+
})
14+
15+
return (
16+
<Form errors={mockErrors} methods={methods} onSubmit={() => {}}>
17+
<PhoneField
18+
defaultCountry="FR"
19+
label="Phone Number"
20+
name="phone"
21+
parseNumberErrorMessage="This doesn't appear to be a valid phone number."
22+
required
23+
/>
24+
</Form>
25+
)
26+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Template } from './Template.stories'
2+
3+
export const Playground = Template.bind({})
4+
5+
Playground.args = {
6+
label: 'Phone Number',
7+
name: 'phone',
8+
placeholder: 'Enter your phone number',
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Template } from './Template.stories'
2+
3+
export const Required = Template.bind({})
4+
5+
Required.args = {
6+
...Template.args,
7+
label: 'Phone Number',
8+
name: 'phone',
9+
required: true,
10+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { PhoneField } from '..'
2+
import { Form } from '../..'
3+
import { useForm } from '../../..'
4+
import { mockErrors } from '../../../mocks'
5+
6+
import type { StoryFn } from '@storybook/react-vite'
7+
8+
export const Template: StoryFn<typeof PhoneField> = ({ ...args }) => (
9+
<Form errors={mockErrors} methods={useForm()} onSubmit={() => {}}>
10+
<PhoneField
11+
{...args}
12+
parseNumberErrorMessage="This doesn't appear to be a valid phone number."
13+
/>
14+
</Form>
15+
)
16+
17+
Template.args = {
18+
label: 'Phone Number',
19+
name: 'phone',
20+
placeholder: 'Enter your phone number',
21+
defaultCountry: 'FR',
22+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Snippet, Stack, Text } from '@ultraviolet/ui'
2+
3+
import { PhoneInputField } from '..'
4+
import { Form } from '../..'
5+
import { useForm } from '../../..'
6+
import { mockErrors } from '../../../mocks'
7+
8+
import type { Meta } from '@storybook/react-vite'
9+
10+
export default {
11+
component: PhoneInputField,
12+
decorators: [
13+
ChildStory => {
14+
const methods = useForm()
15+
const {
16+
errors,
17+
isDirty,
18+
isSubmitting,
19+
touchedFields,
20+
submitCount,
21+
dirtyFields,
22+
isValid,
23+
isLoading,
24+
isSubmitted,
25+
isValidating,
26+
isSubmitSuccessful,
27+
} = methods.formState
28+
29+
return (
30+
<Form errors={mockErrors} methods={methods} onSubmit={() => {}}>
31+
<Stack gap={2}>
32+
<ChildStory />
33+
<Stack gap={1}>
34+
<Text as="p" variant="bodyStrong">
35+
Form input values:
36+
</Text>
37+
<Snippet initiallyExpanded prefix="lines">
38+
{JSON.stringify(methods.watch(), null, 1)}
39+
</Snippet>
40+
</Stack>
41+
<Stack gap={1}>
42+
<Text as="p" variant="bodyStrong">
43+
Form values:
44+
</Text>
45+
<Snippet prefix="lines">
46+
{JSON.stringify(
47+
{
48+
errors,
49+
isDirty,
50+
isSubmitting,
51+
touchedFields,
52+
submitCount,
53+
dirtyFields,
54+
isValid,
55+
isLoading,
56+
isSubmitted,
57+
isValidating,
58+
isSubmitSuccessful,
59+
},
60+
null,
61+
1,
62+
)}
63+
</Snippet>
64+
</Stack>
65+
</Stack>
66+
</Form>
67+
)
68+
},
69+
],
70+
title: 'Form/Components/Fields/PhoneInputField',
71+
} as Meta<typeof PhoneField>
72+
73+
export { Playground } from './Playground.stories'
74+
export { Required } from './Required.stories'
75+
export { WithDefaultCountry } from './DefaultCountry.stories'
76+
export { WithError } from './Error.stories'
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { renderHook, screen, waitFor } from '@testing-library/react'
2+
import { userEvent } from '@testing-library/user-event'
3+
import { mockFormErrors, renderWithForm } from '@utils/test'
4+
import { useForm } from 'react-hook-form'
5+
import { describe, expect, test, vi } from 'vitest'
6+
7+
import { PhoneField } from '..'
8+
import { Submit } from '../..'
9+
import { Form } from '../../Form'
10+
11+
describe('PhoneField', () => {
12+
test('should render correctly', () => {
13+
const { asFragment } = renderWithForm(
14+
<PhoneField
15+
label="Phone Number"
16+
name="phone"
17+
parseNumberErrorMessage="Invalid phone number"
18+
/>,
19+
)
20+
expect(asFragment()).toMatchSnapshot()
21+
})
22+
23+
test('should validate phone number', async () => {
24+
const onSubmit = vi.fn()
25+
const { result } = renderHook(() =>
26+
useForm<{ phone: string }>(),
27+
)
28+
29+
renderWithForm(
30+
<Form
31+
errors={mockFormErrors}
32+
methods={result.current}
33+
onSubmit={onSubmit}
34+
>
35+
<PhoneField
36+
label="Phone Number"
37+
name="phone"
38+
parseNumberErrorMessage="Invalid phone number"
39+
required
40+
/>
41+
<Submit>Submit</Submit>
42+
</Form>,
43+
)
44+
45+
await userEvent.click(screen.getByText('Submit'))
46+
await waitFor(() => {
47+
expect(onSubmit).toHaveBeenCalledTimes(0)
48+
})
49+
50+
const phoneInput = screen.getByRole('textbox')
51+
await userEvent.type(phoneInput, '+33612345678')
52+
await userEvent.click(screen.getByText('Submit'))
53+
await waitFor(() => {
54+
expect(onSubmit).toHaveBeenCalledTimes(1)
55+
})
56+
})
57+
58+
test('should work with default country', async () => {
59+
const onSubmit = vi.fn()
60+
const { result } = renderHook(() =>
61+
useForm<{ phone: string }>(),
62+
)
63+
64+
const { asFragment } = renderWithForm(
65+
<Form
66+
errors={mockFormErrors}
67+
methods={result.current}
68+
onSubmit={onSubmit}
69+
>
70+
<PhoneField
71+
defaultCountry="US"
72+
label="Phone Number"
73+
name="phone"
74+
parseNumberErrorMessage="Invalid phone number"
75+
/>
76+
<Submit>Submit</Submit>
77+
</Form>,
78+
)
79+
80+
const phoneInput = screen.getByRole('textbox')
81+
await userEvent.type(phoneInput, '+12025551234')
82+
await userEvent.click(screen.getByText('Submit'))
83+
await waitFor(() => {
84+
expect(onSubmit.mock.calls[0][0]).toEqual({
85+
phone: '+12025551234',
86+
})
87+
})
88+
expect(asFragment()).toMatchSnapshot()
89+
})
90+
91+
test('should show error for invalid phone number', async () => {
92+
const onSubmit = vi.fn()
93+
const { result } = renderHook(() =>
94+
useForm<{ phone: string }>(),
95+
)
96+
97+
renderWithForm(
98+
<Form
99+
errors={mockFormErrors}
100+
methods={result.current}
101+
onSubmit={onSubmit}
102+
>
103+
<PhoneField
104+
label="Phone Number"
105+
name="phone"
106+
parseNumberErrorMessage="This doesn't appear to be a valid phone number."
107+
required
108+
/>
109+
<Submit>Submit</Submit>
110+
</Form>,
111+
)
112+
113+
const phoneInput = screen.getByRole('textbox')
114+
await userEvent.type(phoneInput, 'invalid')
115+
await userEvent.click(screen.getByText('Submit'))
116+
await waitFor(() => {
117+
expect(onSubmit).toHaveBeenCalledTimes(0)
118+
})
119+
expect(
120+
screen.getByText("This doesn't appear to be a valid phone number."),
121+
).toBeDefined()
122+
})
123+
124+
test('should be disabled when disabled prop is true', () => {
125+
const { asFragment } = renderWithForm(
126+
<PhoneField
127+
disabled
128+
label="Phone Number"
129+
name="phone"
130+
parseNumberErrorMessage="Invalid phone number"
131+
/>,
132+
)
133+
expect(asFragment()).toMatchSnapshot()
134+
})
135+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { PhoneInput } from '@ultraviolet/ui'
2+
import type { BaseFieldProps, FieldPath, FieldValues } from '@ultraviolet/form'
3+
import { useController, useErrors } from '@ultraviolet/form'
4+
import { parsePhoneNumber } from 'awesome-phonenumber'
5+
import type { ComponentProps } from 'react'
6+
7+
type PhoneInputValue = NonNullable<ComponentProps<typeof PhoneInput>['value']>
8+
9+
type PhoneFieldProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>> = BaseFieldProps<
10+
TFieldValues,
11+
TName
12+
> &
13+
ComponentProps<typeof PhoneInput> & {
14+
/**
15+
* message show to the user when the number is not a correct phone number
16+
*/
17+
parseNumberErrorMessage: string
18+
}
19+
20+
export const PhoneField = <
21+
TFieldValues extends FieldValues,
22+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
23+
>({
24+
className,
25+
disabled,
26+
id,
27+
label,
28+
name,
29+
onBlur,
30+
onChange,
31+
onFocus,
32+
required,
33+
defaultCountry,
34+
placeholder,
35+
'data-testid': dataTestId,
36+
parseNumberErrorMessage = "This doesn't appear to be a valid phone number.",
37+
onParsingError,
38+
}: PhoneFieldProps<TFieldValues, TName>) => {
39+
const { getError } = useErrors()
40+
const {
41+
field,
42+
fieldState: { error: fieldError },
43+
} = useController<TFieldValues>({
44+
name,
45+
rules: {
46+
required,
47+
validate: (phoneNumber: PhoneInputValue) => {
48+
try {
49+
return !!phoneNumber && !parsePhoneNumber(phoneNumber).valid ? parseNumberErrorMessage : undefined
50+
} catch (error: unknown) {
51+
if (error instanceof Error) {
52+
onParsingError?.({
53+
error,
54+
inputValue: phoneNumber,
55+
})
56+
}
57+
58+
return phoneNumber
59+
}
60+
},
61+
},
62+
})
63+
64+
return (
65+
<PhoneInput
66+
className={className}
67+
data-testid={dataTestId}
68+
defaultCountry={defaultCountry}
69+
disabled={disabled}
70+
error={getError({ label: label ?? '' }, fieldError)}
71+
id={id}
72+
label={label}
73+
name={field.name}
74+
onBlur={event => {
75+
field.onBlur()
76+
onBlur?.(event)
77+
}}
78+
onChange={event => {
79+
field.onChange(event)
80+
onChange?.(event)
81+
}}
82+
onFocus={event => {
83+
onFocus?.(event)
84+
}}
85+
onParsingError={onParsingError}
86+
placeholder={placeholder}
87+
ref={field.ref}
88+
required={required}
89+
value={field.value}
90+
/>
91+
)
92+
}

0 commit comments

Comments
 (0)