Skip to content

Commit 6ed8fea

Browse files
codegen-sh[bot]Jake Ruesink
andcommitted
Redesign phone input component with separate country select
- Replace built-in country dropdown with separate select input - Match styling with existing text field component - Update stories and tests to reflect new design - Remove custom CSS in favor of existing text field styles Co-authored-by: Jake Ruesink <jake@lambdacurry.com>
1 parent 6567ece commit 6ed8fea

5 files changed

Lines changed: 120 additions & 94 deletions

File tree

apps/docs/src/remix-hook-form/phone-input.stories.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ const ControlledPhoneInputExample = () => {
183183
await userEvent.type(usaPhoneInput, '2025550123');
184184

185185
// Enter an international phone number (UK format)
186-
await userEvent.type(internationalPhoneInput, '447911123456');
186+
await userEvent.type(internationalPhoneInput, '7911123456');
187187

188188
// Submit form
189189
const submitButton = canvas.getByRole('button', { name: 'Submit' });
@@ -222,6 +222,7 @@ export const WithCustomStyling: Story = {
222222
defaultCountry="US"
223223
className="border-2 border-blue-500 p-4 rounded-lg"
224224
inputClassName="bg-gray-100"
225+
selectClassName="bg-gray-100 border-blue-300"
225226
/>
226227
</div>
227228
<Button type="submit" className="mt-8">

apps/docs/src/remix-hook-form/phone-input.test.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ describe('PhoneInput Component', () => {
8686
// Check for descriptions
8787
expect(screen.getByText('Enter a US phone number')).toBeInTheDocument();
8888
expect(screen.getByText('Enter an international phone number')).toBeInTheDocument();
89+
90+
// Check for country select
91+
expect(screen.getAllByLabelText('Country code')).toHaveLength(2);
8992
});
9093

9194
it('displays validation errors when provided', async () => {
@@ -103,7 +106,7 @@ describe('PhoneInput Component', () => {
103106
});
104107

105108
describe('Input Behavior', () => {
106-
it('allows entering a US phone number', async () => {
109+
it('allows entering a phone number', async () => {
107110
const user = userEvent.setup();
108111
render(<TestPhoneInputForm />);
109112

@@ -112,25 +115,25 @@ describe('PhoneInput Component', () => {
112115
// Type a US phone number
113116
await user.type(usaPhoneInput, '2025550123');
114117

115-
// Check that the input contains the formatted number
118+
// Check that the input contains the number
116119
await waitFor(() => {
117-
expect(usaPhoneInput).toHaveValue('(202) 555-0123');
120+
expect(usaPhoneInput).toHaveValue('2025550123');
118121
});
119122
});
120123

121-
it('allows entering an international phone number', async () => {
124+
it('allows selecting a different country', async () => {
122125
const user = userEvent.setup();
123126
render(<TestPhoneInputForm />);
124127

125-
const internationalPhoneInput = screen.getByLabelText('International Phone Number');
128+
// Get the country select for international phone
129+
const countrySelects = screen.getAllByLabelText('Country code');
130+
const internationalCountrySelect = countrySelects[1];
126131

127-
// Type a UK phone number
128-
await user.type(internationalPhoneInput, '447911123456');
132+
// Change country to UK (GB)
133+
await user.selectOptions(internationalCountrySelect, 'GB');
129134

130-
// Check that the input contains the formatted number
131-
await waitFor(() => {
132-
expect(internationalPhoneInput).toHaveValue('+44 7911 123456');
133-
});
135+
// Check that the select has the new value
136+
expect(internationalCountrySelect).toHaveValue('GB');
134137
});
135138
});
136139

@@ -166,6 +169,10 @@ describe('PhoneInput Component', () => {
166169
// Verify labels are properly associated with inputs
167170
expect(screen.getByLabelText('USA Phone Number')).toBeInTheDocument();
168171
expect(screen.getByLabelText('International Phone Number')).toBeInTheDocument();
172+
173+
// Verify country selects have proper aria-labels
174+
const countrySelects = screen.getAllByLabelText('Country code');
175+
expect(countrySelects).toHaveLength(2);
169176
});
170177
});
171178
});

packages/components/src/ui/phone-input-field.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface PhoneInputFieldProps extends Omit<PhoneInputProps, 'value' | 'o
2121
Input?: React.ComponentType<PhoneInputProps & React.RefAttributes<HTMLInputElement>>;
2222
};
2323
className?: string;
24+
inputClassName?: string;
25+
selectClassName?: string;
2426
}
2527

2628
export const PhoneInputField = function PhoneInputField({
@@ -29,6 +31,8 @@ export const PhoneInputField = function PhoneInputField({
2931
label,
3032
description,
3133
className,
34+
inputClassName,
35+
selectClassName,
3236
components,
3337
ref,
3438
...props
@@ -49,7 +53,8 @@ export const PhoneInputField = function PhoneInputField({
4953
{...field}
5054
{...props}
5155
ref={ref}
52-
className={cn('focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2')}
56+
inputClassName={inputClassName}
57+
selectClassName={selectClassName}
5358
/>
5459
</FormControl>
5560
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}

packages/components/src/ui/phone-input.css

Lines changed: 0 additions & 71 deletions
This file was deleted.
Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import * as React from 'react';
2-
import PhoneInput from 'react-phone-number-input';
3-
import 'react-phone-number-input/style.css';
4-
import './phone-input.css';
2+
import { getCountries, getCountryCallingCode } from 'react-phone-number-input/input';
3+
import { parsePhoneNumber, AsYouType, isValidPhoneNumber } from 'libphonenumber-js';
54
import { cn } from './utils';
65

6+
// Import country flags
7+
import 'country-flag-icons/css/flag-icons.min.css';
8+
79
export interface PhoneInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
810
value?: string;
911
onChange?: (value?: string) => void;
1012
defaultCountry?: string;
1113
international?: boolean;
1214
className?: string;
1315
inputClassName?: string;
16+
selectClassName?: string;
1417
}
1518

1619
export const PhoneNumberInput = ({
@@ -20,20 +23,101 @@ export const PhoneNumberInput = ({
2023
international = true,
2124
className,
2225
inputClassName,
26+
selectClassName,
2327
...props
2428
}: PhoneInputProps & { ref?: React.Ref<HTMLInputElement> }) => {
29+
const [selectedCountry, setSelectedCountry] = React.useState(defaultCountry);
30+
const [inputValue, setInputValue] = React.useState('');
31+
const inputRef = React.useRef<HTMLInputElement>(null);
32+
33+
// Get list of countries
34+
const countries = React.useMemo(() => getCountries(), []);
35+
36+
// Format the full phone number (with country code)
37+
const formatFullNumber = React.useCallback((country: string, nationalNumber: string) => {
38+
if (!nationalNumber) return '';
39+
40+
const formatter = new AsYouType(country);
41+
const formatted = formatter.input(nationalNumber);
42+
43+
if (international) {
44+
return `+${getCountryCallingCode(country)}${formatted.startsWith('+') ? formatted.substring(1) : formatted}`;
45+
}
46+
47+
return formatted;
48+
}, [international]);
49+
50+
// Initialize input value from props
51+
React.useEffect(() => {
52+
if (value) {
53+
try {
54+
const phoneNumber = parsePhoneNumber(value);
55+
if (phoneNumber) {
56+
setSelectedCountry(phoneNumber.country || defaultCountry);
57+
setInputValue(phoneNumber.nationalNumber || '');
58+
}
59+
} catch (error) {
60+
// If parsing fails, just use the value as is
61+
setInputValue(value);
62+
}
63+
} else {
64+
setInputValue('');
65+
}
66+
}, [value, defaultCountry]);
67+
68+
// Handle country change
69+
const handleCountryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
70+
const newCountry = e.target.value;
71+
setSelectedCountry(newCountry);
72+
73+
// Update the full number with the new country code
74+
const fullNumber = formatFullNumber(newCountry, inputValue);
75+
onChange?.(fullNumber);
76+
};
77+
78+
// Handle input change
79+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
80+
const newInput = e.target.value;
81+
setInputValue(newInput);
82+
83+
// Update the full number
84+
const fullNumber = formatFullNumber(selectedCountry, newInput);
85+
onChange?.(fullNumber);
86+
};
87+
2588
return (
26-
<div className={cn('phone-input-container', className)}>
27-
<PhoneInput
28-
value={value}
29-
onChange={onChange}
30-
defaultCountry={defaultCountry}
31-
international={international}
32-
className={cn('phone-input', inputClassName)}
89+
<div className={cn('flex gap-2', className)}>
90+
<select
91+
value={selectedCountry}
92+
onChange={handleCountryChange}
93+
className={cn(
94+
'flex h-10 w-auto text-base sm:text-sm rounded-md border border-input bg-background px-3 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
95+
selectClassName
96+
)}
97+
aria-label="Country code"
98+
>
99+
{countries.map((country) => (
100+
<option key={country} value={country}>
101+
{country} +{getCountryCallingCode(country)}
102+
</option>
103+
))}
104+
</select>
105+
106+
<input
107+
ref={inputRef}
108+
type="tel"
109+
value={inputValue}
110+
onChange={handleInputChange}
111+
className={cn(
112+
'flex h-10 w-full text-base sm:text-sm rounded-md border border-input bg-background px-3 py-2 ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
113+
inputClassName
114+
)}
115+
data-slot="input"
33116
{...props}
34117
/>
35118
</div>
36119
);
37120
};
38121

39122
PhoneNumberInput.displayName = 'PhoneNumberInput';
123+

0 commit comments

Comments
 (0)