Skip to content

Commit 09467c2

Browse files
authored
[PLAY-1720] Phone Number Input formatAsYouType Prop (#4125)
**What does this PR do?** - Add `formatAsYouType`/`format_as_you_type` prop to Phone Number Input kit - Keep most of Phone Number Input logic the same if this prop is false - Replace `telInputInit` state, `itiInit`, with a ref, `itiRef`, so it's available across renders - In React, remove formatting (dashes, spaces, parentheses) in the `number` prop in the onChange event - Make docs and tests **Screenshots:** ![Screenshot 2025-01-14 at 2 04 26 PM](https://github.com/user-attachments/assets/180ea59c-e8a5-4145-96f8-fe8b44f82169) **How to test?** Steps to confirm the desired behavior: 1. Go to /kits/phone_number_input/react#format-as-you-type 2. Type numbers and note that it formats 3. Check other doc examples 4. Review Rails versions #### Checklist: - [x] **LABELS** Add a label: `enhancement`, `bug`, `improvement`, `new kit`, `deprecated`, or `breaking`. See [Changelog & Labels](https://github.com/powerhome/playbook/wiki/Changelog-&-Labels) for details. - [x] **DEPLOY** I have added the `milano` label to show I'm ready for a review. - [x] **TESTS** I have added test coverage to my code.
1 parent fba7adb commit 09467c2

9 files changed

+98
-14
lines changed

playbook/app/pb_kits/playbook/pb_phone_number_input/_phone_number_input.tsx

+30-12
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type PhoneNumberInputProps = {
3535
preferredCountries?: string[],
3636
required?: boolean,
3737
value?: string,
38+
formatAsYouType?: boolean,
3839
}
3940

4041
enum ValidationError {
@@ -87,6 +88,7 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.MutableRefOb
8788
required = false,
8889
preferredCountries = [],
8990
value = "",
91+
formatAsYouType = false,
9092
} = props
9193

9294
const ariaProps = buildAriaProps(aria)
@@ -99,8 +101,8 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.MutableRefOb
99101
)
100102

101103
const inputRef = useRef<HTMLInputElement>()
104+
const itiRef = useRef<any>(null);
102105
const [inputValue, setInputValue] = useState(value)
103-
const [itiInit, setItiInit] = useState<any>()
104106
const [error, setError] = useState(props.error)
105107
const [dropDownIsOpen, setDropDownIsOpen] = useState(false)
106108
const [selectedData, setSelectedData] = useState()
@@ -130,8 +132,12 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.MutableRefOb
130132
}
131133
})
132134

135+
const unformatNumber = (formattedNumber: any) => {
136+
return formattedNumber.replace(/\D/g, "")
137+
}
138+
133139
const showFormattedError = (reason = '') => {
134-
const countryName = itiInit.getSelectedCountryData().name
140+
const countryName = itiRef.current.getSelectedCountryData().name
135141
const reasonText = reason.length > 0 ? ` (${reason})` : ''
136142
setError(`Invalid ${countryName} phone number${reasonText}`)
137143
return true
@@ -189,12 +195,12 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.MutableRefOb
189195
}
190196

191197
const validateErrors = () => {
192-
if (itiInit) isValid(itiInit.isValidNumber())
193-
if (validateOnlyNumbers(itiInit)) return
194-
if (validateTooLongNumber(itiInit)) return
195-
if (validateTooShortNumber(itiInit)) return
196-
if (validateUnhandledError(itiInit)) return
197-
if (validateMissingAreaCode(itiInit)) return
198+
if (itiRef.current) isValid(itiRef.current.isValidNumber())
199+
if (validateOnlyNumbers(itiRef.current)) return
200+
if (validateTooLongNumber(itiRef.current)) return
201+
if (validateTooShortNumber(itiRef.current)) return
202+
if (validateUnhandledError(itiRef.current)) return
203+
if (validateMissingAreaCode(itiRef.current)) return
198204
}
199205

200206
const getCurrentSelectedData = (itiInit: any, inputValue: string) => {
@@ -203,10 +209,16 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.MutableRefOb
203209

204210
const handleOnChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
205211
setInputValue(evt.target.value)
206-
const phoneNumberData = getCurrentSelectedData(itiInit, evt.target.value)
212+
let phoneNumberData
213+
if (formatAsYouType) {
214+
const formattedPhoneNumberData = getCurrentSelectedData(itiRef.current, evt.target.value)
215+
phoneNumberData = {...formattedPhoneNumberData, number: unformatNumber(formattedPhoneNumberData.number)}
216+
} else {
217+
phoneNumberData = getCurrentSelectedData(itiRef.current, evt.target.value)
218+
}
207219
setSelectedData(phoneNumberData)
208220
onChange(phoneNumberData)
209-
isValid(itiInit.isValidNumber())
221+
isValid(itiRef.current.isValidNumber())
210222
}
211223

212224
// Separating Concerns as React Docs Recommend
@@ -230,9 +242,11 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.MutableRefOb
230242
onlyCountries,
231243
countrySearch: false,
232244
fixDropdownWidth: false,
233-
formatAsYouType: false,
245+
formatAsYouType: formatAsYouType,
234246
})
235247

248+
itiRef.current = telInputInit;
249+
236250
inputRef.current.addEventListener("countrychange", (evt: Event) => {
237251
const phoneNumberData = getCurrentSelectedData(telInputInit, (evt.target as HTMLInputElement).value)
238252
setSelectedData(phoneNumberData)
@@ -243,7 +257,11 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.MutableRefOb
243257
inputRef.current.addEventListener("open:countrydropdown", () => setDropDownIsOpen(true))
244258
inputRef.current.addEventListener("close:countrydropdown", () => setDropDownIsOpen(false))
245259

246-
setItiInit(telInputInit)
260+
if (formatAsYouType) {
261+
inputRef.current?.addEventListener("input", (evt) => {
262+
handleOnChange(evt as unknown as React.ChangeEvent<HTMLInputElement>);
263+
});
264+
}
247265
}, [])
248266

249267
let textInputProps: {[key: string]: any} = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<%= pb_rails("phone_number_input", props: {
2+
id: "phone_number_input",
3+
format_as_you_type: true
4+
}) %>
5+
6+
<%= pb_rails("button", props: {id: "clickable", text: "Save Phone Number"}) %>
7+
8+
<%= javascript_tag do %>
9+
document.querySelector('#clickable').addEventListener('click', () => {
10+
const formattedPhoneNumber = document.querySelector('#phone_number_input').value
11+
const unformattedPhoneNumber = formattedPhoneNumber.replace(/\D/g, "")
12+
13+
alert(`Formatted: ${formattedPhoneNumber}. Unformatted: ${unformattedPhoneNumber}`)
14+
})
15+
<% end %>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React, { useState } from "react";
2+
import { PhoneNumberInput, Body } from "playbook-ui";
3+
4+
const PhoneNumberInputFormat = (props) => {
5+
const [phoneNumber, setPhoneNumber] = useState("");
6+
7+
const handleOnChange = ({ number }) => {
8+
setPhoneNumber(number);
9+
};
10+
11+
return (
12+
<>
13+
<PhoneNumberInput
14+
formatAsYouType
15+
id="format"
16+
onChange={handleOnChange}
17+
{...props}
18+
/>
19+
{phoneNumber && <Body>Unformatted number: {phoneNumber}</Body>}
20+
</>
21+
);
22+
};
23+
24+
export default PhoneNumberInputFormat;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
NOTE: the `number` in the React `onChange` event will not include formatting (no spaces, dashes, and parentheses). For Rails, the `value` will include formatting and its value must be sanitized manually.

playbook/app/pb_kits/playbook/pb_phone_number_input/docs/example.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ examples:
88
- phone_number_input_validation: Form Validation
99
- phone_number_input_clear_field: Clearing the Input Field
1010
- phone_number_input_access_input_element: Accessing the Input Element
11+
- phone_number_input_format: Format as You Type
1112

1213
rails:
1314
- phone_number_input_default: Default
1415
- phone_number_input_preferred_countries: Preferred Countries
1516
- phone_number_input_initial_country: Initial Country
1617
- phone_number_input_only_countries: Limited Countries
17-
- phone_number_input_validation: Form Validation
18+
- phone_number_input_validation: Form Validation
19+
- phone_number_input_format: Format as You Type

playbook/app/pb_kits/playbook/pb_phone_number_input/docs/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { default as PhoneNumberInputOnlyCountries } from './_phone_number_input_
55
export { default as PhoneNumberInputValidation } from './_phone_number_input_validation'
66
export { default as PhoneNumberInputClearField } from './_phone_number_input_clear_field'
77
export { default as PhoneNumberInputAccessInputElement } from './_phone_number_input_access_input_element'
8+
export { default as PhoneNumberInputFormat } from './_phone_number_input_format'

playbook/app/pb_kits/playbook/pb_phone_number_input/phone_number_input.rb

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class PhoneNumberInput < Playbook::KitBase
2121
default: ""
2222
prop :value, type: Playbook::Props::String,
2323
default: ""
24+
prop :format_as_you_type, type: Playbook::Props::Boolean,
25+
default: false
2426

2527
def classname
2628
generate_classname("pb_phone_number_input")
@@ -32,6 +34,7 @@ def phone_number_input_options
3234
dark: dark,
3335
disabled: disabled,
3436
error: error,
37+
formatAsYouType: format_as_you_type,
3538
initialCountry: initial_country,
3639
label: label,
3740
name: name,

playbook/app/pb_kits/playbook/pb_phone_number_input/phone_number_input.test.js

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { render, screen } from "../utilities/test-utils";
2+
import { render, screen, act } from "../utilities/test-utils";
33
import PhoneNumberInput from "./_phone_number_input";
44

55
const testId = "phoneNumberInput";
@@ -120,3 +120,22 @@ test("should trigger callback", () => {
120120

121121
expect(handleOnValidate).toBeCalledWith(true)
122122
});
123+
124+
test("should format phone number as '555-555-5555' with formatAsYouType and 'us' country", () => {
125+
const props = {
126+
initialCountry: 'us',
127+
formatAsYouType: true,
128+
id: testId,
129+
};
130+
131+
render(<PhoneNumberInput {...props} />);
132+
133+
const input = screen.getByRole("textbox");
134+
135+
act(() => {
136+
input.value = "5555555555";
137+
input.dispatchEvent(new Event('input', { bubbles: true }));
138+
});
139+
140+
expect(input.value).toBe("555-555-5555");
141+
});

playbook/spec/pb_kits/playbook/kits/phone_number_input_spec.rb

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
it { is_expected.to define_array_prop(:preferred_countries).with_default([]) }
1515
it { is_expected.to define_prop(:error).with_default("") }
1616
it { is_expected.to define_prop(:value).with_default("") }
17+
it { is_expected.to define_boolean_prop(:format_as_you_type).with_default(false) }
1718

1819
describe "#classname" do
1920
it "returns namespaced class name", :aggregate_failures do

0 commit comments

Comments
 (0)