Skip to content

Commit 6567ece

Browse files
codegen-sh[bot]Jake Ruesink
andcommitted
Add phone input component with react-phone-number-input integration
- Create UI phone input component - Create form field wrapper for phone input - Create remix-hook-form wrapper component - Add stories with USA and international phone number examples - Add tests for phone input component - Add custom CSS for better styling integration Co-authored-by: Jake Ruesink <jake@lambdacurry.com>
1 parent 461abfe commit 6567ece

10 files changed

Lines changed: 795 additions & 2 deletions

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { PhoneInput } from '@lambdacurry/forms/remix-hook-form/phone-input';
3+
import { Button } from '@lambdacurry/forms/ui/button';
4+
import type { Meta, StoryObj } from '@storybook/react-vite';
5+
import { expect, userEvent, within } from '@storybook/test';
6+
import { type ActionFunctionArgs, useFetcher } from 'react-router';
7+
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
8+
import { z } from 'zod';
9+
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
10+
11+
// Define a schema for phone number validation
12+
const formSchema = z.object({
13+
usaPhone: z.string().min(1, 'USA phone number is required'),
14+
internationalPhone: z.string().min(1, 'International phone number is required'),
15+
});
16+
17+
type FormData = z.infer<typeof formSchema>;
18+
19+
const ControlledPhoneInputExample = () => {
20+
const fetcher = useFetcher<{ message: string }>();
21+
const methods = useRemixForm<FormData>({
22+
resolver: zodResolver(formSchema),
23+
defaultValues: {
24+
usaPhone: '',
25+
internationalPhone: '',
26+
},
27+
fetcher,
28+
submitConfig: {
29+
action: '/',
30+
method: 'post',
31+
},
32+
});
33+
34+
return (
35+
<RemixFormProvider {...methods}>
36+
<fetcher.Form onSubmit={methods.handleSubmit}>
37+
<div className="grid gap-8">
38+
<PhoneInput
39+
name="usaPhone"
40+
label="USA Phone Number"
41+
description="Enter a US phone number"
42+
defaultCountry="US"
43+
international={false}
44+
/>
45+
<PhoneInput
46+
name="internationalPhone"
47+
label="International Phone Number"
48+
description="Enter an international phone number"
49+
international={true}
50+
/>
51+
</div>
52+
<Button type="submit" className="mt-8">
53+
Submit
54+
</Button>
55+
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
56+
</fetcher.Form>
57+
</RemixFormProvider>
58+
);
59+
};
60+
61+
const handleFormSubmission = async (request: Request) => {
62+
const { data, errors } = await getValidatedFormData<FormData>(request, zodResolver(formSchema));
63+
64+
if (errors) {
65+
return { errors };
66+
}
67+
68+
return {
69+
message: `Form submitted successfully! USA: ${data.usaPhone}, International: ${data.internationalPhone}`
70+
};
71+
};
72+
73+
const meta: Meta<typeof PhoneInput> = {
74+
title: 'RemixHookForm/PhoneInput',
75+
component: PhoneInput,
76+
parameters: { layout: 'centered' },
77+
tags: ['autodocs'],
78+
decorators: [
79+
withReactRouterStubDecorator({
80+
routes: [
81+
{
82+
path: '/',
83+
Component: ControlledPhoneInputExample,
84+
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
85+
},
86+
],
87+
}),
88+
],
89+
} satisfies Meta<typeof PhoneInput>;
90+
91+
export default meta;
92+
type Story = StoryObj<typeof meta>;
93+
94+
export const Default: Story = {
95+
parameters: {
96+
docs: {
97+
description: {
98+
story: 'Phone input component with US and international number support.',
99+
},
100+
source: {
101+
code: `
102+
const formSchema = z.object({
103+
usaPhone: z.string().min(1, 'USA phone number is required'),
104+
internationalPhone: z.string().min(1, 'International phone number is required'),
105+
});
106+
107+
const ControlledPhoneInputExample = () => {
108+
const fetcher = useFetcher<{ message: string }>();
109+
const methods = useRemixForm<FormData>({
110+
resolver: zodResolver(formSchema),
111+
defaultValues: {
112+
usaPhone: '',
113+
internationalPhone: '',
114+
},
115+
fetcher,
116+
submitConfig: {
117+
action: '/',
118+
method: 'post',
119+
},
120+
});
121+
122+
return (
123+
<RemixFormProvider {...methods}>
124+
<fetcher.Form onSubmit={methods.handleSubmit}>
125+
<div className="grid gap-8">
126+
<PhoneInput
127+
name="usaPhone"
128+
label="USA Phone Number"
129+
description="Enter a US phone number"
130+
defaultCountry="US"
131+
international={false}
132+
/>
133+
<PhoneInput
134+
name="internationalPhone"
135+
label="International Phone Number"
136+
description="Enter an international phone number"
137+
international={true}
138+
/>
139+
</div>
140+
<Button type="submit" className="mt-8">
141+
Submit
142+
</Button>
143+
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
144+
</fetcher.Form>
145+
</RemixFormProvider>
146+
);
147+
};`,
148+
},
149+
},
150+
},
151+
play: async ({ canvasElement, step }) => {
152+
const canvas = within(canvasElement);
153+
154+
await step('Verify initial state', async () => {
155+
// Verify phone input fields are present
156+
const usaPhoneLabel = canvas.getByLabelText('USA Phone Number');
157+
const internationalPhoneLabel = canvas.getByLabelText('International Phone Number');
158+
159+
expect(usaPhoneLabel).toBeInTheDocument();
160+
expect(internationalPhoneLabel).toBeInTheDocument();
161+
162+
// Verify submit button is present
163+
const submitButton = canvas.getByRole('button', { name: 'Submit' });
164+
expect(submitButton).toBeInTheDocument();
165+
});
166+
167+
await step('Test validation errors on invalid submission', async () => {
168+
// Submit form without entering phone numbers
169+
const submitButton = canvas.getByRole('button', { name: 'Submit' });
170+
await userEvent.click(submitButton);
171+
172+
// Verify validation error messages appear
173+
await expect(canvas.findByText('USA phone number is required')).resolves.toBeInTheDocument();
174+
await expect(canvas.findByText('International phone number is required')).resolves.toBeInTheDocument();
175+
});
176+
177+
await step('Test successful form submission with valid phone numbers', async () => {
178+
// Enter valid phone numbers
179+
const usaPhoneInput = canvas.getByLabelText('USA Phone Number');
180+
const internationalPhoneInput = canvas.getByLabelText('International Phone Number');
181+
182+
// Enter a US phone number
183+
await userEvent.type(usaPhoneInput, '2025550123');
184+
185+
// Enter an international phone number (UK format)
186+
await userEvent.type(internationalPhoneInput, '447911123456');
187+
188+
// Submit form
189+
const submitButton = canvas.getByRole('button', { name: 'Submit' });
190+
await userEvent.click(submitButton);
191+
192+
// Verify success message
193+
await expect(canvas.findByText(/Form submitted successfully/)).resolves.toBeInTheDocument();
194+
});
195+
},
196+
};
197+
198+
export const WithCustomStyling: Story = {
199+
render: () => {
200+
const fetcher = useFetcher<{ message: string }>();
201+
const methods = useRemixForm<FormData>({
202+
resolver: zodResolver(formSchema),
203+
defaultValues: {
204+
usaPhone: '',
205+
internationalPhone: '',
206+
},
207+
fetcher,
208+
submitConfig: {
209+
action: '/',
210+
method: 'post',
211+
},
212+
});
213+
214+
return (
215+
<RemixFormProvider {...methods}>
216+
<fetcher.Form onSubmit={methods.handleSubmit}>
217+
<div className="grid gap-8">
218+
<PhoneInput
219+
name="usaPhone"
220+
label="Custom Styled Phone Input"
221+
description="With custom styling applied"
222+
defaultCountry="US"
223+
className="border-2 border-blue-500 p-4 rounded-lg"
224+
inputClassName="bg-gray-100"
225+
/>
226+
</div>
227+
<Button type="submit" className="mt-8">
228+
Submit
229+
</Button>
230+
</fetcher.Form>
231+
</RemixFormProvider>
232+
);
233+
},
234+
parameters: {
235+
docs: {
236+
description: {
237+
story: 'Phone input with custom styling applied.',
238+
},
239+
},
240+
},
241+
};
242+
243+
export const WithDifferentDefaultCountries: Story = {
244+
render: () => {
245+
const fetcher = useFetcher<{ message: string }>();
246+
const methods = useRemixForm<{
247+
usPhone: string;
248+
ukPhone: string;
249+
canadaPhone: string;
250+
australiaPhone: string;
251+
}>({
252+
resolver: zodResolver(
253+
z.object({
254+
usPhone: z.string().optional(),
255+
ukPhone: z.string().optional(),
256+
canadaPhone: z.string().optional(),
257+
australiaPhone: z.string().optional(),
258+
})
259+
),
260+
defaultValues: {
261+
usPhone: '',
262+
ukPhone: '',
263+
canadaPhone: '',
264+
australiaPhone: '',
265+
},
266+
fetcher,
267+
submitConfig: {
268+
action: '/',
269+
method: 'post',
270+
},
271+
});
272+
273+
return (
274+
<RemixFormProvider {...methods}>
275+
<fetcher.Form onSubmit={methods.handleSubmit}>
276+
<div className="grid gap-8">
277+
<PhoneInput
278+
name="usPhone"
279+
label="US Phone Number"
280+
defaultCountry="US"
281+
international={true}
282+
/>
283+
<PhoneInput
284+
name="ukPhone"
285+
label="UK Phone Number"
286+
defaultCountry="GB"
287+
international={true}
288+
/>
289+
<PhoneInput
290+
name="canadaPhone"
291+
label="Canada Phone Number"
292+
defaultCountry="CA"
293+
international={true}
294+
/>
295+
<PhoneInput
296+
name="australiaPhone"
297+
label="Australia Phone Number"
298+
defaultCountry="AU"
299+
international={true}
300+
/>
301+
</div>
302+
<Button type="submit" className="mt-8">
303+
Submit
304+
</Button>
305+
</fetcher.Form>
306+
</RemixFormProvider>
307+
);
308+
},
309+
parameters: {
310+
docs: {
311+
description: {
312+
story: 'Phone inputs with different default countries.',
313+
},
314+
},
315+
},
316+
};
317+

0 commit comments

Comments
 (0)