Skip to content

Commit f88686e

Browse files
authored
feat(frontend): Input Phone Field (#147)
1 parent cf0736c commit f88686e

File tree

5 files changed

+238
-0
lines changed

5 files changed

+238
-0
lines changed

Diff for: frontend/app/components/input-phone-field.tsx

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { useState } from 'react';
2+
3+
import type { E164Number } from 'libphonenumber-js';
4+
import PhoneInput from 'react-phone-number-input/input';
5+
6+
import { InputError } from '~/components/input-error';
7+
import { InputHelp } from '~/components/input-help';
8+
import { InputLabel } from '~/components/input-label';
9+
import { cn } from '~/utils/tailwind-utils';
10+
11+
const inputBaseClassName =
12+
'block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500';
13+
const inputDisabledClassName =
14+
'disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70';
15+
const inputReadOnlyClassName =
16+
'read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70';
17+
const inputErrorClassName = 'border-red-500 focus:border-red-500 focus:ring-red-500';
18+
19+
export interface InputPhoneFieldProps
20+
extends OmitStrict<
21+
React.ComponentProps<typeof PhoneInput>,
22+
'aria-errormessage' | 'aria-invalid' | 'aria-labelledby' | 'aria-required' | 'children' | 'value'
23+
> {
24+
defaultValue?: string;
25+
errorMessage?: string;
26+
helpMessagePrimary?: React.ReactNode;
27+
helpMessagePrimaryClassName?: string;
28+
helpMessageSecondary?: React.ReactNode;
29+
helpMessageSecondaryClassName?: string;
30+
id: string;
31+
label: string;
32+
name: string;
33+
}
34+
35+
export function InputPhoneField({
36+
'aria-describedby': ariaDescribedby,
37+
className,
38+
defaultValue,
39+
defaultCountry,
40+
errorMessage,
41+
helpMessagePrimary,
42+
helpMessagePrimaryClassName,
43+
helpMessageSecondary,
44+
helpMessageSecondaryClassName,
45+
id,
46+
label,
47+
required,
48+
...restProps
49+
}: InputPhoneFieldProps) {
50+
const [value, setValue] = useState(defaultValue);
51+
52+
const inputWrapperId = `input-phone-field-${id}`;
53+
const inputErrorId = `${inputWrapperId}-error`;
54+
const inputHelpMessagePrimaryId = `${inputWrapperId}-help-primary`;
55+
const inputHelpMessageSecondaryId = `${inputWrapperId}-help-secondary`;
56+
const inputLabelId = `${inputWrapperId}-label`;
57+
58+
function getAriaDescribedby() {
59+
const describedby = [];
60+
if (ariaDescribedby) describedby.push(ariaDescribedby);
61+
if (helpMessagePrimary) describedby.push(inputHelpMessagePrimaryId);
62+
if (helpMessageSecondary) describedby.push(inputHelpMessageSecondaryId);
63+
return describedby.length > 0 ? describedby.join(' ') : undefined;
64+
}
65+
66+
function handleOnPhoneInputChange(value?: E164Number) {
67+
setValue(value);
68+
}
69+
70+
return (
71+
<div id={inputWrapperId} data-testid={inputWrapperId}>
72+
<InputLabel id={inputLabelId} htmlFor={id} className="mb-2">
73+
{label}
74+
</InputLabel>
75+
{errorMessage && (
76+
<p className="mb-2">
77+
<InputError id={inputErrorId}>{errorMessage}</InputError>
78+
</p>
79+
)}
80+
{helpMessagePrimary && (
81+
<InputHelp
82+
id={inputHelpMessagePrimaryId}
83+
className={cn('mb-2', helpMessagePrimaryClassName)}
84+
data-testid="input-phone-field-help-primary"
85+
>
86+
{helpMessagePrimary}
87+
</InputHelp>
88+
)}
89+
<PhoneInput
90+
aria-describedby={getAriaDescribedby()}
91+
aria-errormessage={errorMessage ? inputErrorId : undefined}
92+
aria-invalid={!!errorMessage}
93+
aria-labelledby={inputLabelId}
94+
aria-required={required}
95+
data-testid="input-phone-field"
96+
defaultCountry={defaultCountry ?? 'CA'}
97+
id={id}
98+
className={cn(
99+
inputBaseClassName,
100+
inputDisabledClassName,
101+
inputReadOnlyClassName,
102+
errorMessage && inputErrorClassName,
103+
className,
104+
)}
105+
onChange={handleOnPhoneInputChange}
106+
required={required}
107+
value={value}
108+
{...restProps}
109+
/>
110+
{helpMessageSecondary && (
111+
<InputHelp
112+
id={inputHelpMessageSecondaryId}
113+
className={cn('mt-2', helpMessageSecondaryClassName)}
114+
data-testid="input-phone-field-help-secondary"
115+
>
116+
{helpMessageSecondary}
117+
</InputHelp>
118+
)}
119+
</div>
120+
);
121+
}

Diff for: frontend/package-lock.json

+57
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: frontend/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"react-dom": "^19.0.0",
6262
"react-i18next": "^15.4.0",
6363
"react-number-format": "^5.4.3",
64+
"react-phone-number-input": "^3.4.11",
6465
"react-router": "^7.1.3",
6566
"source-map-support": "^0.5.21",
6667
"tailwind-merge": "^2.6.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`InputPhoneField > should render > expected html 1`] = `"<div id="input-phone-field-test-id" data-testid="input-phone-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-phone-field-test-id-label" for="test-id"><span>label test</span></label><input aria-invalid="false" aria-labelledby="input-phone-field-test-id-label" data-testid="input-phone-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" autocomplete="tel" type="tel" value="(514) 666-7777" name="test"></div>"`;
4+
5+
exports[`InputPhoneField > should render international phone number > expected html 1`] = `"<div id="input-phone-field-test-id" data-testid="input-phone-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-phone-field-test-id-label" for="test-id"><span>label test</span></label><input aria-invalid="false" aria-labelledby="input-phone-field-test-id-label" data-testid="input-phone-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" autocomplete="tel" type="tel" value="+506 4444 4444" name="test"></div>"`;
6+
7+
exports[`InputPhoneField > should render with error message > expected html 1`] = `"<div id="input-phone-field-test-id" data-testid="input-phone-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-phone-field-test-id-label" for="test-id"><span>label test</span></label><p class="mb-2"><span class="inline-block max-w-prose border-l-2 border-red-600 bg-red-50 px-3 py-1" data-testid="input-error-test-id" role="alert" id="input-phone-field-test-id-error">error message</span></p><input aria-errormessage="input-phone-field-test-id-error" aria-invalid="true" aria-labelledby="input-phone-field-test-id-label" data-testid="input-phone-field" id="test-id" class="block rounded-lg focus:outline-none focus:ring disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70 border-red-500 focus:border-red-500 focus:ring-red-500" autocomplete="tel" type="tel" value="(514) 666-7777" name="test"></div>"`;
8+
9+
exports[`InputPhoneField > should render with help message > expected html 1`] = `"<div id="input-phone-field-test-id" data-testid="input-phone-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-phone-field-test-id-label" for="test-id"><span>label test</span></label><input aria-describedby="input-phone-field-test-id-help-secondary" aria-invalid="false" aria-labelledby="input-phone-field-test-id-label" data-testid="input-phone-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" autocomplete="tel" type="tel" value="(514) 666-7777" name="test"><span class="block max-w-prose text-gray-500 mt-2" data-testid="input-phone-field-help-secondary" id="input-phone-field-test-id-help-secondary">help message</span></div>"`;
10+
11+
exports[`InputPhoneField > should render with required > expected html 1`] = `"<div id="input-phone-field-test-id" data-testid="input-phone-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-phone-field-test-id-label" for="test-id"><span>label test</span></label><input aria-invalid="false" aria-labelledby="input-phone-field-test-id-label" aria-required="true" data-testid="input-phone-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" required="" autocomplete="tel" type="tel" value="(514) 666-7777" name="test"></div>"`;

Diff for: frontend/tests/components/input-phone-field.test.tsx

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { InputPhoneField } from '~/components/input-phone-field';
5+
6+
describe('InputPhoneField', () => {
7+
it('should render', () => {
8+
const phoneNumber = '+15146667777';
9+
const { container } = render(<InputPhoneField id="test-id" name="test" label="label test" defaultValue={phoneNumber} />);
10+
expect(container.innerHTML).toMatchSnapshot('expected html');
11+
});
12+
13+
it('should render international phone number', () => {
14+
const phoneNumber = '+50644444444';
15+
const { container } = render(<InputPhoneField id="test-id" name="test" label="label test" defaultValue={phoneNumber} />);
16+
expect(container.innerHTML).toMatchSnapshot('expected html');
17+
});
18+
19+
it('should render with help message', () => {
20+
const phoneNumber = '+15146667777';
21+
const { container } = render(
22+
<InputPhoneField
23+
id="test-id"
24+
name="test"
25+
label="label test"
26+
defaultValue={phoneNumber}
27+
helpMessageSecondary="help message"
28+
/>,
29+
);
30+
expect(container.innerHTML).toMatchSnapshot('expected html');
31+
});
32+
33+
it('should render with required', () => {
34+
const phoneNumber = '+15146667777';
35+
const { container } = render(
36+
<InputPhoneField id="test-id" name="test" label="label test" defaultValue={phoneNumber} required />,
37+
);
38+
expect(container.innerHTML).toMatchSnapshot('expected html');
39+
});
40+
41+
it('should render with error message', () => {
42+
const phoneNumber = '+15146667777';
43+
const { container } = render(
44+
<InputPhoneField id="test-id" name="test" label="label test" defaultValue={phoneNumber} errorMessage="error message" />,
45+
);
46+
expect(container.innerHTML).toMatchSnapshot('expected html');
47+
});
48+
});

0 commit comments

Comments
 (0)