From 4cba05f26bc3b7a426eba442924b7c78ec34b909 Mon Sep 17 00:00:00 2001 From: Manjiro Sano Date: Sun, 23 Nov 2025 13:38:05 +0530 Subject: [PATCH] Create phone-input.tsx feat(ui): Add PhoneInput component with international formatting support This commit adds a new PhoneInput component that provides: - International phone number formatting using libphonenumber-js - Real-time validation for phone numbers - Support for EN (GB), DE, and PL country codes - Auto-formatting as users type - Proper handling of pasted phone numbers - Default country code support (PL as default) - Three variants: PhoneInput, PhoneInputWithLabel, and PhoneInputWithDetails - Consistent API with existing Input component Addresses #306 --- packages/ui/src/elements/phone-input.tsx | 235 +++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 packages/ui/src/elements/phone-input.tsx diff --git a/packages/ui/src/elements/phone-input.tsx b/packages/ui/src/elements/phone-input.tsx new file mode 100644 index 000000000..d2dc37b98 --- /dev/null +++ b/packages/ui/src/elements/phone-input.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; +import { parsePhoneNumber, AsYouType, CountryCode } from 'libphonenumber-js'; +import { Input, InputWithLabel, InputWithDetails, InputProps, InputWithDetailsProps } from './input'; +import { cn } from '@o2s/ui/lib/utils'; + +// Minimal metadata for supported countries (EN, DE, PL) +const SUPPORTED_COUNTRIES: CountryCode[] = ['GB', 'DE', 'PL']; + +export type PhoneInputProps = Omit & { + defaultCountry?: CountryCode; + value?: string; + onChange?: (value: string, isValid: boolean) => void; + onValidationChange?: (isValid: boolean) => void; +}; + +export type PhoneInputOwnProps = PhoneInputProps & { ref?: React.Ref }; + +const PhoneInput = React.forwardRef( + ({ defaultCountry = 'PL', value = '', onChange, onValidationChange, ...props }, ref) => { + const [displayValue, setDisplayValue] = React.useState(value); + const [isValid, setIsValid] = React.useState(false); + + React.useEffect(() => { + setDisplayValue(value); + }, [value]); + + const formatPhoneNumber = React.useCallback( + (input: string) => { + if (!input) return ''; + + try { + // Try to parse the phone number + const asYouType = new AsYouType(defaultCountry); + const formatted = asYouType.input(input); + + // Check if it's valid + const phoneNumber = asYouType.getNumber(); + const valid = phoneNumber ? phoneNumber.isValid() : false; + + setIsValid(valid); + onValidationChange?.(valid); + + return formatted; + } catch (error) { + setIsValid(false); + onValidationChange?.(false); + return input; + } + }, + [defaultCountry, onValidationChange] + ); + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + + // Only allow valid characters (numbers, +, spaces, parentheses, hyphens) + const sanitized = inputValue.replace(/[^0-9+\s()-]/g, ''); + + const formatted = formatPhoneNumber(sanitized); + setDisplayValue(formatted); + onChange?.(formatted, isValid); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData('text'); + const sanitized = pastedText.replace(/[^0-9+\s()-]/g, ''); + const formatted = formatPhoneNumber(sanitized); + setDisplayValue(formatted); + onChange?.(formatted, isValid); + }; + + return ( + + ); + } +); + +PhoneInput.displayName = 'PhoneInput'; + +export type PhoneInputWithLabelProps = Omit & { + defaultCountry?: CountryCode; + value?: string; + onChange?: (value: string, isValid: boolean) => void; + onValidationChange?: (isValid: boolean) => void; +}; + +const PhoneInputWithLabel = React.forwardRef( + ({ defaultCountry = 'PL', value = '', onChange, onValidationChange, ...props }, ref) => { + const [displayValue, setDisplayValue] = React.useState(value); + const [isValid, setIsValid] = React.useState(false); + + React.useEffect(() => { + setDisplayValue(value); + }, [value]); + + const formatPhoneNumber = React.useCallback( + (input: string) => { + if (!input) return ''; + + try { + const asYouType = new AsYouType(defaultCountry); + const formatted = asYouType.input(input); + const phoneNumber = asYouType.getNumber(); + const valid = phoneNumber ? phoneNumber.isValid() : false; + + setIsValid(valid); + onValidationChange?.(valid); + + return formatted; + } catch (error) { + setIsValid(false); + onValidationChange?.(false); + return input; + } + }, + [defaultCountry, onValidationChange] + ); + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + const sanitized = inputValue.replace(/[^0-9+\s()-]/g, ''); + const formatted = formatPhoneNumber(sanitized); + setDisplayValue(formatted); + onChange?.(formatted, isValid); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData('text'); + const sanitized = pastedText.replace(/[^0-9+\s()-]/g, ''); + const formatted = formatPhoneNumber(sanitized); + setDisplayValue(formatted); + onChange?.(formatted, isValid); + }; + + return ( + + ); + } +); + +PhoneInputWithLabel.displayName = 'PhoneInputWithLabel'; + +export type PhoneInputWithDetailsProps = Omit & { + defaultCountry?: CountryCode; + value?: string; + onChange?: (value: string, isValid: boolean) => void; + onValidationChange?: (isValid: boolean) => void; +}; + +const PhoneInputWithDetails = React.forwardRef( + ({ defaultCountry = 'PL', value = '', onChange, onValidationChange, caption, errorMessage, ...props }, ref) => { + const [displayValue, setDisplayValue] = React.useState(value); + const [isValid, setIsValid] = React.useState(false); + + React.useEffect(() => { + setDisplayValue(value); + }, [value]); + + const formatPhoneNumber = React.useCallback( + (input: string) => { + if (!input) return ''; + + try { + const asYouType = new AsYouType(defaultCountry); + const formatted = asYouType.input(input); + const phoneNumber = asYouType.getNumber(); + const valid = phoneNumber ? phoneNumber.isValid() : false; + + setIsValid(valid); + onValidationChange?.(valid); + + return formatted; + } catch (error) { + setIsValid(false); + onValidationChange?.(false); + return input; + } + }, + [defaultCountry, onValidationChange] + ); + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + const sanitized = inputValue.replace(/[^0-9+\s()-]/g, ''); + const formatted = formatPhoneNumber(sanitized); + setDisplayValue(formatted); + onChange?.(formatted, isValid); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData('text'); + const sanitized = pastedText.replace(/[^0-9+\s()-]/g, ''); + const formatted = formatPhoneNumber(sanitized); + setDisplayValue(formatted); + onChange?.(formatted, isValid); + }; + + return ( + + ); + } +); + +PhoneInputWithDetails.displayName = 'PhoneInputWithDetails'; + +export { PhoneInput, PhoneInputWithLabel, PhoneInputWithDetails };