From 604c012464fd975a0083e15ee248b1f80f9f5804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Wed, 27 Nov 2024 13:34:04 +0100 Subject: [PATCH] Improve composite inputs (#2794) --- .changeset/slimy-spoons-rule.md | 5 + .changeset/swift-frogs-laugh.md | 5 + .../components/ColorInput/ColorInput.spec.tsx | 49 +++++++-- .../ColorInput/ColorInput.stories.tsx | 6 +- .../components/ColorInput/ColorInput.tsx | 89 +++++++-------- .../ColorInput/ColorInputService.spec.ts | 102 ++++++++++++++++++ .../ColorInput/ColorInputService.ts | 35 ++++++ .../components/DateInput/DateInput.spec.tsx | 33 ------ .../DateInput/components/DateSegment.tsx | 5 +- .../PhoneNumberInput.spec.tsx | 92 +++++++++++++--- .../PhoneNumberInput/PhoneNumberInput.tsx | 10 +- .../PhoneNumberInputService.spec.ts | 62 ++++++++++- .../PhoneNumberInputService.ts | 43 +++++--- 13 files changed, 413 insertions(+), 123 deletions(-) create mode 100644 .changeset/slimy-spoons-rule.md create mode 100644 .changeset/swift-frogs-laugh.md create mode 100644 packages/circuit-ui/components/ColorInput/ColorInputService.spec.ts create mode 100644 packages/circuit-ui/components/ColorInput/ColorInputService.ts diff --git a/.changeset/slimy-spoons-rule.md b/.changeset/slimy-spoons-rule.md new file mode 100644 index 0000000000..1f41b1e01b --- /dev/null +++ b/.changeset/slimy-spoons-rule.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Added support for typing and pasting 3-character hex code into the ColorInput component. diff --git a/.changeset/swift-frogs-laugh.md b/.changeset/swift-frogs-laugh.md new file mode 100644 index 0000000000..6cb1714dec --- /dev/null +++ b/.changeset/swift-frogs-laugh.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Improved parsing of the PhoneNumberInput component's `value` and `defaultValue` props. diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx index 5acc6f9e45..5c4a2b7942 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx @@ -105,16 +105,17 @@ describe('ColorInput', () => { }); it('should ignore an invalid value', () => { - render(); + render(); const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label); expect(colorInput).toHaveValue('#000000'); - expect(textInput).toHaveValue('fff'); + expect(textInput).toHaveValue('ffg'); }); }); describe('user interactions', () => { const newValue = '00ff00'; + it('should update text input if color input changes', async () => { const onChange = vi.fn(); render(); @@ -124,6 +125,13 @@ describe('ColorInput', () => { expect(textInput).toHaveValue(newValue.replace('#', '')); expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: `#${newValue}`, + }), + }), + ); }); it('should update color input if text input changes', async () => { @@ -134,11 +142,19 @@ describe('ColorInput', () => { await userEvent.type(textInput, newValue); expect(colorInput).toHaveValue(`#${newValue}`); - expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: `#${newValue}`, + }), + }), + ); }); it('should handle paste events', async () => { - render(); + const onChange = vi.fn(); + render(); const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label); await userEvent.click(textInput); @@ -146,21 +162,32 @@ describe('ColorInput', () => { expect(colorInput).toHaveValue(`#${newValue}`); expect(textInput).toHaveValue(newValue); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: `#${newValue}`, + }), + }), + ); }); it('should ignore invalid paste event', async () => { - render(); + const onChange = vi.fn(); + render(); const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label); await userEvent.click(textInput); await userEvent.paste('obviously invalid'); expect(colorInput).toHaveValue('#000000'); - expect(textInput).toHaveValue(''); + expect(textInput).toHaveValue('obviou'); + expect(onChange).not.toHaveBeenCalled(); }); it("should allow pasting color without '#'", async () => { - render(); + const onChange = vi.fn(); + render(); const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label); await userEvent.click(textInput); @@ -168,6 +195,14 @@ describe('ColorInput', () => { expect(colorInput).toHaveValue(`#${newValue}`); expect(textInput).toHaveValue(newValue); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: `#${newValue}`, + }), + }), + ); }); }); }); diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx index 1d52c6d3c8..6d99b28657 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx @@ -25,7 +25,6 @@ export default { const baseArgs = { label: 'Color', - placeholder: '#99ffbb', defaultValue: '#99ffbb', }; @@ -55,9 +54,10 @@ export const Validations = (args: ColorInputProps) => ( /> ( hasWarning, showValid, hideLabel, - id, invalid, label, onChange, @@ -101,55 +107,54 @@ export const ColorInput = forwardRef( const descriptionIds = clsx(validationHintId, descriptionId); + const updatePickerValue = useCallback((color: string) => { + if (!colorPickerRef.current || !isValidColor(color)) { + return; + } + + changeInputValue(colorPickerRef.current, normalizeColor(color)); + }, []); + + const updateInputValue = useCallback((color?: string) => { + if (!colorInputRef.current || !color) { + return; + } + + const currentColor = colorInputRef.current.value; + + if (!isSameColor(currentColor, color)) { + changeInputValue(colorInputRef.current, color.trim().replace('#', '')); + } + }, []); + + useEffect(() => { + updateInputValue(value); + }, [updateInputValue, value]); + const handlePaste: ClipboardEventHandler = (event) => { if (!colorPickerRef.current || !colorInputRef.current || readOnly) { return; } - event.preventDefault(); - const pastedText = event.clipboardData.getData('text/plain').trim(); - if (!pastedText || !/^#?[0-9A-F]{6}$/i.test(pastedText)) { + if (!pastedText || !isValidColor(pastedText)) { return; } - const pastedColor = pastedText.startsWith('#') - ? pastedText - : `#${pastedText}`; - - colorPickerRef.current.value = pastedColor; - colorPickerRef.current.dispatchEvent( - new Event('change', { bubbles: true }), - ); + event.preventDefault(); - changeInputValue(colorInputRef.current, pastedColor.replace('#', '')); + updatePickerValue(pastedText); + updateInputValue(pastedText); }; - const onPickerColorChange: ChangeEventHandler = ( - event, - ) => { - if (colorInputRef.current) { - colorInputRef.current.value = event.target.value.replace('#', ''); - } - if (onChange) { - onChange(event); - } + const onPickerChange: ChangeEventHandler = (event) => { + onChange?.(event); + updateInputValue(event.target.value); }; const onInputChange: ChangeEventHandler = (event) => { - if (colorPickerRef.current) { - colorPickerRef.current.value = `#${event.target.value}`; - } - if (onChange) { - onChange({ - ...event, - target: { - ...event.target, - value: `#${event.target.value}`, - }, - }); - } + updatePickerValue(event.target.value); }; return ( @@ -174,16 +179,18 @@ export const ColorInput = forwardRef( type="color" aria-labelledby={labelId} aria-describedby={descriptionIds} + aria-invalid={invalid ? 'true' : undefined} className={classes['color-input']} - onChange={onPickerColorChange} + onChange={onPickerChange} disabled={disabled || readOnly} - defaultValue={defaultValue} - value={value} + required={required} + defaultValue={defaultValue && normalizeColor(defaultValue)} + value={value && normalizeColor(value)} + {...props} /> # ( classes.input, inputClassName, )} - aria-invalid={invalid && 'true'} + aria-invalid={invalid ? 'true' : undefined} required={required} maxLength={6} pattern="[0-9a-f]{3,6}" readOnly={readOnly} disabled={disabled} - value={value?.replace('#', '')} - defaultValue={defaultValue?.replace('#', '')} + defaultValue={(defaultValue || value)?.replace('#', '')} placeholder={placeholder?.replace('#', '')} onChange={onInputChange} onPaste={handlePaste} - {...props} /> { + describe('isValidColor', () => { + it.each(['3F2', '#f3d', '123456', '#ABCDEF', '#A9C7d3'])( + 'should return true for %s', + (color) => { + const actual = isValidColor(color); + expect(actual).toBe(true); + }, + ); + + it('should return false if the color contains letters outside the range A-F', () => { + const actual = isValidColor('#ABG'); + expect(actual).toBe(false); + }); + + it('should return false if the color is too short', () => { + const actual = isValidColor('#a0'); + expect(actual).toBe(false); + }); + + it('should return false if the color is between 3 and 6 characters long', () => { + const actual = isValidColor('#04f28'); + expect(actual).toBe(false); + }); + + it('should return false if the color is too long', () => { + const actual = isValidColor('a04f288'); + expect(actual).toBe(false); + }); + }); + + describe('isSameColor', () => { + it('should return true if the colors are identical', () => { + const actual = isSameColor('#fff', '#fff'); + expect(actual).toBe(true); + }); + + it.each([ + ['#f0d', '#ff00dd'], + ['a04f28', '#a04f28'], + ['#aabbcc', '#AABBCC'], + ])( + 'should return true if the colors are equivalent (%s, %s)', + (colorA, colorB) => { + const actual = isSameColor(colorA, colorB); + expect(actual).toBe(true); + }, + ); + + it.each([ + ['#f0e', '#ff00dd'], + ['a04f28', '#a04f27'], + ['#a04f28', '#a04f27'], + ])( + 'should return false if the colors are different (%s, %s)', + (colorA, colorB) => { + const actual = isSameColor(colorA, colorB); + expect(actual).toBe(false); + }, + ); + }); + + describe('normalizeColor', () => { + it('should prefix a hashtag', () => { + const actual = normalizeColor('aabbcc'); + expect(actual).toBe('#aabbcc'); + }); + + it('should expand 3-char hex codes to 6 chars', () => { + const actual = normalizeColor('aB4'); + expect(actual).toBe('#aabb44'); + }); + + it('should lowercase the color', () => { + const actual = normalizeColor('#AA00bb'); + expect(actual).toBe('#aa00bb'); + }); + }); +}); diff --git a/packages/circuit-ui/components/ColorInput/ColorInputService.ts b/packages/circuit-ui/components/ColorInput/ColorInputService.ts new file mode 100644 index 0000000000..25cb1964bf --- /dev/null +++ b/packages/circuit-ui/components/ColorInput/ColorInputService.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isValidColor(color: string) { + return /^#?(?:[0-9A-F]{3}){1,2}$/i.test(color); +} + +export function isSameColor(colorA: string, colorB: string) { + return colorA === colorB || normalizeColor(colorA) === normalizeColor(colorB); +} + +export function normalizeColor(value: string) { + let color = value.trim().replace('#', ''); + + if (color.length === 3) { + color = color + .split('') + .flatMap((char) => [char, char]) + .join(''); + } + + return `#${color}`.toLowerCase(); +} diff --git a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx index d01be6a221..f31548c0f0 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx @@ -185,39 +185,6 @@ describe('DateInput', () => { '2001', ); }); - - it.todo( - 'should mark the year input as readonly when the minimum and maximum dates have the same year', - () => { - render(); - expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly'); - expect(screen.getByLabelText(/month/i)).toHaveAttribute( - 'aria-valuemin', - '4', - ); - expect(screen.getByLabelText(/month/i)).toHaveAttribute( - 'aria-valuemax', - '6', - ); - }, - ); - - it.todo( - 'should mark the year and month inputs as readonly when the minimum and maximum dates have the same year and month', - () => { - render(); - expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly'); - expect(screen.getByLabelText(/month/i)).toHaveAttribute('readonly'); - expect(screen.getByLabelText(/day/i)).toHaveAttribute( - 'aria-valuemin', - '9', - ); - expect(screen.getByLabelText(/day/i)).toHaveAttribute( - 'aria-valuemax', - '27', - ); - }, - ); }); describe('state', () => { diff --git a/packages/circuit-ui/components/DateInput/components/DateSegment.tsx b/packages/circuit-ui/components/DateInput/components/DateSegment.tsx index 767162267f..8f22415c67 100644 --- a/packages/circuit-ui/components/DateInput/components/DateSegment.tsx +++ b/packages/circuit-ui/components/DateInput/components/DateSegment.tsx @@ -73,8 +73,9 @@ export function DateSegment({ useLayoutEffect(() => { if (sizeRef.current) { const cursorWidth = 1; - const { offsetWidth } = sizeRef.current; - setWidth(`${cursorWidth + offsetWidth}px`); + const elementSize = sizeRef.current.getBoundingClientRect(); + const elementWidth = Math.ceil(elementSize.width); + setWidth(`${cursorWidth + elementWidth}px`); } }, [props.value]); diff --git a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.spec.tsx b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.spec.tsx index 4b9956e602..86f62b4f18 100644 --- a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.spec.tsx +++ b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.spec.tsx @@ -45,6 +45,10 @@ const defaultProps = { }, }; +function getHiddenInput(container: HTMLElement) { + return container.querySelectorAll('input')[0]; +} + describe('PhoneNumberInput', () => { it('should merge a custom class name with the default ones', () => { const props = { @@ -61,7 +65,7 @@ describe('PhoneNumberInput', () => { const { container } = render( , ); - const input = container.querySelectorAll('input')[0]; + const input = getHiddenInput(container); expect(ref.current).toBe(input); }); @@ -76,27 +80,71 @@ describe('PhoneNumberInput', () => { expect(ref.current).toBe(select); }); - it('should forward a ref to the subscriber number input', () => { - const ref = createRef(); + it('should forward a ref to the country code input', () => { + const ref = createRef(); const props = { ...defaultProps, - subscriberNumber: { ...defaultProps.subscriberNumber, ref }, + countryCode: { ...defaultProps.countryCode, ref }, }; render(); - const input = screen.getByLabelText('Subscriber number'); + const input = screen.getByLabelText('Country code'); expect(ref.current).toBe(input); }); - it('should call onChange when there is a change', async () => { - const onChange = vi.fn(); + it('should forward a ref to the subscriber number input', () => { + const ref = createRef(); const props = { ...defaultProps, - onChange, + subscriberNumber: { ...defaultProps.subscriberNumber, ref }, }; render(); - const select = screen.getByRole('combobox'); - await userEvent.selectOptions(select, 'DE'); - expect(onChange).toHaveBeenCalledOnce(); + const input = screen.getByLabelText('Subscriber number'); + expect(ref.current).toBe(input); + }); + + describe('semantics', () => { + it('should accept a custom description via aria-describedby', () => { + const customDescription = 'Custom description'; + const customDescriptionId = 'customDescriptionId'; + render( + <> + {customDescription} + + , + ); + const countryCode = screen.getByLabelText('Country code'); + const subscriberNumber = screen.getByLabelText('Subscriber number'); + expect(countryCode).toHaveAccessibleDescription(customDescription); + expect(subscriberNumber).toHaveAccessibleDescription(customDescription); + }); + + it('should render as disabled', async () => { + render(); + + const countryCode = screen.getByLabelText('Country code'); + const subscriberNumber = screen.getByLabelText('Subscriber number'); + expect(countryCode).toBeDisabled(); + expect(subscriberNumber).toBeDisabled(); + }); + + it('should render as read-only', async () => { + render(); + const countryCode = screen.getByLabelText('Country code'); + const subscriberNumber = screen.getByLabelText('Subscriber number'); + expect(countryCode).toHaveAttribute('readonly'); + expect(subscriberNumber).toHaveAttribute('readonly'); + }); + + it('should render as required', async () => { + render(); + const countryCode = screen.getByLabelText('Country code'); + const subscriberNumber = screen.getByLabelText('Subscriber number'); + expect(countryCode).toBeRequired(); + expect(subscriberNumber).toBeRequired(); + }); }); it('should display a default value', () => { @@ -105,7 +153,7 @@ describe('PhoneNumberInput', () => { defaultValue: '+4912345678', }; const { container } = render(); - const input = container.querySelectorAll('input')[0]; + const input = getHiddenInput(container); const countryCode = screen.getByLabelText('Country code'); const subscriberNumber = screen.getByLabelText('Subscriber number'); expect(input).toHaveValue('+4912345678'); @@ -119,7 +167,7 @@ describe('PhoneNumberInput', () => { value: '+4912345678', }; const { container } = render(); - const input = container.querySelectorAll('input')[0]; + const input = getHiddenInput(container); const countryCode = screen.getByLabelText('Country code'); const subscriberNumber = screen.getByLabelText('Subscriber number'); expect(input).toHaveValue('+4912345678'); @@ -132,7 +180,7 @@ describe('PhoneNumberInput', () => { , ); rerender(); - const input = container.querySelectorAll('input')[0]; + const input = getHiddenInput(container); const countryCode = screen.getByLabelText('Country code'); const subscriberNumber = screen.getByLabelText('Subscriber number'); expect(input).toHaveValue('+112345678'); @@ -140,6 +188,18 @@ describe('PhoneNumberInput', () => { expect(subscriberNumber).toHaveValue('12345678'); }); + it('should call onChange when there is a change', async () => { + const onChange = vi.fn(); + const props = { + ...defaultProps, + onChange, + }; + render(); + const select = screen.getByRole('combobox'); + await userEvent.selectOptions(select, 'DE'); + expect(onChange).toHaveBeenCalledOnce(); + }); + it('should call countryCode onChange when there is a change in the country code', async () => { const onChange = vi.fn(); const props = { @@ -232,7 +292,7 @@ describe('PhoneNumberInput', () => { expect(input).toBeValid(); }); - it('should flag the input field as invalid when the pattern is not matching', () => { + it('should flag the subscriber number field as invalid when the pattern is not matching', () => { const props = { ...defaultProps, subscriberNumber: { @@ -261,7 +321,7 @@ describe('PhoneNumberInput', () => { expect(fieldset).toHaveAttribute('aria-describedby'); }); - it('should throw accessibility error when the label is not sufficiently labelled and the hideLabel prop is not set', () => { + it('should throw accessibility error when the field is not sufficiently labelled', () => { const props = { ...defaultProps, label: undefined, diff --git a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx index 1cc61ad65d..fd7bf52495 100644 --- a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx +++ b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx @@ -305,14 +305,14 @@ export const PhoneNumberInput = forwardRef< if (!isSufficientlyLabelled(countryCode.label)) { throw new AccessibilityError( 'PhoneNumberInput', - 'The `countryCodeLabel` prop is missing or invalid.', + 'The `countryCode.label` prop is missing or invalid.', ); } if (!isSufficientlyLabelled(subscriberNumber.label)) { throw new AccessibilityError( 'PhoneNumberInput', - 'The `subscriberNumberLabel` prop is missing or invalid.', + 'The `subscriberNumber.label` prop is missing or invalid.', ); } } @@ -352,7 +352,9 @@ export const PhoneNumberInput = forwardRef< {readOnly || countryCode.readonly ? ( { + describe('parsePhoneNumber', () => { + const options = [ + { country: 'US', code: '+1' }, + { country: 'CA', code: '+1' }, + { country: 'DE', code: '+49' }, + ]; + + it('should parse an empty phone number', () => { + const phoneNumber = ''; + const actual = parsePhoneNumber(phoneNumber, options); + expect(actual.countryCode).toBeUndefined(); + expect(actual.subscriberNumber).toBeUndefined(); + }); + + it('should parse a full, well-formatted phone number', () => { + const phoneNumber = '+1 707 555 2323'; + const actual = parsePhoneNumber(phoneNumber, options); + expect(actual.countryCode).toBe('US'); + expect(actual.subscriberNumber).toBe('707 555 2323'); + }); + + it('should parse a full phone number with unsupported characters', () => { + const phoneNumber = '+1 (707) 555-2323'; + const actual = parsePhoneNumber(phoneNumber, options); + expect(actual.countryCode).toBe('US'); + expect(actual.subscriberNumber).toBe('707 555 2323'); + }); + + it('should parse a phone number with a double-0 prefixed country code', () => { + const phoneNumber = '001 (707) 555-2323'; + const actual = parsePhoneNumber(phoneNumber, options); + expect(actual.countryCode).toBe('US'); + expect(actual.subscriberNumber).toBe('707 555 2323'); + }); + + it('should parse a phone number without a country code', () => { + const phoneNumber = '(707) 555-2323'; + const actual = parsePhoneNumber(phoneNumber, options); + expect(actual.countryCode).toBeUndefined(); + expect(actual.subscriberNumber).toBe('707 555 2323'); + }); + + it('should parse a phone number with an unsupported country code', () => { + const phoneNumber = '+99 (707) 555-2323'; + const actual = parsePhoneNumber(phoneNumber, options); + expect(actual.countryCode).toBeUndefined(); + expect(actual.subscriberNumber).toBe('+99 707 555 2323'); + }); + + it('should parse a phone number without a subscriber number', () => { + const phoneNumber = '+49'; + const actual = parsePhoneNumber(phoneNumber, options); + expect(actual.countryCode).toBe('DE'); + expect(actual.subscriberNumber).toBeUndefined(); + }); + }); + describe('normalizePhoneNumber', () => { it('should merge the country code and subscriber number', () => { const countryCode = '+1'; @@ -29,11 +87,11 @@ describe('PhoneNumberInputService', () => { expect(actual).toBe('+123456789'); }); - it('should strip non-numeric, non-whitespace characters from the subscriber number', () => { + it('should replace non-numeric, non-whitespace characters in the subscriber number', () => { const countryCode = '+1'; const subscriberNumber = '(234) 567-8910'; const actual = normalizePhoneNumber(countryCode, subscriberNumber); - expect(actual).toBe('+1234 5678910'); + expect(actual).toBe('+1 234 567 8910'); }); it('should replace unsupported whitespace characters with single spaces in the subscriber number', () => { diff --git a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInputService.ts b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInputService.ts index 4082375f40..594ce28a6d 100644 --- a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInputService.ts +++ b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInputService.ts @@ -33,8 +33,11 @@ export function parsePhoneNumber( countryCode?: string; subscriberNumber?: string; } { - // TODO: Normalize the value further const sanitizedValue = value + // Strip non-numeric, non-whitespace characters + ?.replace(/[^+0-9\s]/g, ' ') + // Replace unsupported whitespace characters with simple space + ?.replace(/\s+/g, ' ') ?.trim() // Normalize the country code prefix ?.replace(/^00/, '+'); @@ -62,8 +65,13 @@ export function parsePhoneNumber( }; } - // TODO: Handle non-existent subscriber number - const subscriberNumber = sanitizedValue.split(matchedOption.code)[1]; + const subscriberNumber = sanitizedValue.split(matchedOption.code)[1].trim(); + + if (!subscriberNumber) { + return { + countryCode: matchedOption.country, + }; + } return { countryCode: matchedOption.country, @@ -77,9 +85,9 @@ export function normalizePhoneNumber( ) { const normalizedSubscriberNumber = subscriberNumber // Strip non-numeric, non-whitespace characters - .replace(/[^0-9\s]/g, '') + .replace(/[^0-9\s]/g, ' ') // Replace unsupported whitespace characters with simple space - .replace(/\s/g, ' ') + .replace(/\s+/g, ' ') // Strip any leading zeros .replace(/^0+/, ''); return `${countryCode}${normalizedSubscriberNumber}`; @@ -89,20 +97,23 @@ export function mapCountryCodeOptions( countryCodeOptions: { country: string; code: string }[], locale?: string | string[], ): Required['options'] { - // eslint-disable-next-line compat/compat - const isIntlDisplayNamesSupported = typeof Intl.DisplayNames === 'function'; - if (!isIntlDisplayNamesSupported) { + const getCountryName = (country: string) => { + // eslint-disable-next-line compat/compat + const isIntlDisplayNamesSupported = typeof Intl.DisplayNames === 'function'; + // When Intl.DisplayNames is not supported, we can't provide the localized country names - return countryCodeOptions.map(({ code, country }) => ({ - label: `${country} (${code})`, - value: code, - })); - } - // eslint-disable-next-line compat/compat - const displayName = new Intl.DisplayNames(locale, { type: 'region' }); + if (!isIntlDisplayNamesSupported || !country) { + return country; + } + + // eslint-disable-next-line compat/compat + const displayName = new Intl.DisplayNames(locale, { type: 'region' }); + return displayName.of(country); + }; + return countryCodeOptions .map(({ code, country }) => { - const countryName = country ? displayName.of(country) : country; + const countryName = getCountryName(country); return { label: countryName ? `${countryName} (${code})` : code, value: country,