Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions packages/ui/src/elements/phone-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import * as React from 'react';
import { parsePhoneNumber, AsYouType, CountryCode } from 'libphonenumber-js';
Copy link

@coderabbitai coderabbitai bot Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused import.

The parsePhoneNumber import is never used in this file.

Apply this diff:

-import { parsePhoneNumber, AsYouType, CountryCode } from 'libphonenumber-js';
+import { AsYouType, CountryCode } from 'libphonenumber-js';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { parsePhoneNumber, AsYouType, CountryCode } from 'libphonenumber-js';
import { AsYouType, CountryCode } from 'libphonenumber-js';
🤖 Prompt for AI Agents
In packages/ui/src/elements/phone-input.tsx around line 2, the import list
includes parsePhoneNumber which is unused; remove parsePhoneNumber from the
import statement so it only imports AsYouType and CountryCode from
'libphonenumber-js' to eliminate the dead import and any linter warnings.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify bundle size impact and metadata approach.

The PR objectives and issue #306 specifically requested minimizing bundle size by using MetadataJson with metadata for only the supported countries (GB, DE, PL) instead of full metadata. However, the current implementation imports from libphonenumber-js, which includes metadata for all 200+ countries.

Please verify whether:

  1. Tree-shaking effectively removes unused country metadata with your bundler configuration, or
  2. You should use libphonenumber-js/min or libphonenumber-js/core with custom metadata as described in the libphonenumber-js documentation.

Run the following script to check the current bundle size impact:


🏁 Script executed:

#!/bin/bash
# Check if custom metadata is configured and measure bundle impact

echo "Checking for libphonenumber-js usage in packages/ui..."
rg -n "libphonenumber-js" packages/ui/

echo -e "\nChecking package.json for libphonenumber-js..."
cat packages/ui/package.json | jq '.dependencies."libphonenumber-js"'

echo -e "\nSearching for any metadata configuration..."
fd -e ts -e tsx -e js -e jsx . packages/ui/ --exec rg -l "MetadataJson|metadata\.min\.json|libphonenumber-js/(min|core)"

Length of output: 273


🏁 Script executed:

#!/bin/bash
# Check root package.json and monorepo structure

echo "Checking root package.json for libphonenumber-js..."
cat package.json | jq '.dependencies."libphonenumber-js" // .devDependencies."libphonenumber-js" // empty'

echo -e "\nChecking for bundler configuration..."
ls -la | grep -E "vite|webpack|esbuild|tsconfig"

echo -e "\nSearching entire repo for custom metadata approach..."
rg -n "metadata" --type=ts --type=tsx --type=json -C2 | head -50

echo -e "\nListing all references to phone-input..."
fd -n "phone-input" --type=f

Length of output: 513


🏁 Script executed:

#!/bin/bash
# Search for the specific issue mentioned in the review comment (#306)
# to understand the actual requirements

echo "Searching commit messages or PR context for issue #306..."
git log --all --oneline --grep="306\|MetadataJson\|supported countries" 2>/dev/null | head -20

echo -e "\nSearching for any custom metadata JSON files..."
fd -e json . | grep -i metadata

Length of output: 180


🏁 Script executed:

#!/bin/bash
# Check for monorepo configuration and dependency resolution

echo "Checking root package.json for workspace configuration..."
cat package.json | jq '.workspaces // empty'

echo -e "\nChecking packages/ui/package.json completely..."
cat packages/ui/package.json

echo -e "\nSearching for any libphonenumber-js installation or reference..."
rg "libphonenumber" -i

echo -e "\nChecking if there's a lock file that might have the dependency..."
ls -la | grep -E "package-lock|yarn.lock|pnpm-lock"

Length of output: 3567


🏁 Script executed:

#!/bin/bash
# Check root package.json for libphonenumber-js and verify workspace dependency resolution

echo "Checking root package.json dependencies..."
cat package.json | jq '.dependencies, .devDependencies' | grep -A 50 "libphonenumber"

echo -e "\nChecking complete root package.json for libphonenumber-js..."
rg "libphonenumber" package.json

echo -e "\nVerifying if libphonenumber-js is in node_modules..."
ls -la node_modules/ 2>/dev/null | grep -i libphone || echo "node_modules check skipped (not in sandbox)"

echo -e "\nChecking phone-input.tsx file contents..."
cat packages/ui/src/elements/phone-input.tsx

Length of output: 7794


Add libphonenumber-js dependency to packages/ui/package.json and implement custom metadata to minimize bundle size.

Two critical issues were found:

  1. Missing dependency declaration: libphonenumber-js is imported but not declared in packages/ui/package.json. In this monorepo workspace structure, the package must explicitly declare its dependencies.

  2. Bundle size not optimized: The code includes a comment about "Minimal metadata for supported countries" and defines SUPPORTED_COUNTRIES for only 3 countries (GB, DE, PL), but the implementation uses the full libphonenumber-js library which bundles metadata for 200+ countries. To minimize bundle size as intended, implement custom metadata by using libphonenumber-js/min with a custom metadata file containing only the required countries' data, as documented in the libphonenumber-js documentation.

Required changes:

  • Add "libphonenumber-js": "<version>" to packages/ui/package.json dependencies
  • Replace the current import with libphonenumber-js/min and provide custom metadata

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mithilP007 yes, please optimize loaded metadata so that not all countries are imported

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

import { Input, InputWithLabel, InputWithDetails, InputProps, InputWithDetailsProps } from './input';
import { cn } from '@o2s/ui/lib/utils';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused import.

The cn utility import is never used in this file.

Apply this diff:

-import { cn } from '@o2s/ui/lib/utils';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { cn } from '@o2s/ui/lib/utils';
🤖 Prompt for AI Agents
In packages/ui/src/elements/phone-input.tsx around line 4, the import "cn" from
'@o2s/ui/lib/utils' is unused; remove that import statement to clean up the file
and avoid linting errors, ensuring no other parts of the file reference "cn"
before committing.


// Minimal metadata for supported countries (EN, DE, PL)
const SUPPORTED_COUNTRIES: CountryCode[] = ['GB', 'DE', 'PL'];
Comment on lines +6 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove or utilize the unused constant.

The SUPPORTED_COUNTRIES constant is defined but never referenced. Either remove it or use it to validate the defaultCountry prop.

🤖 Prompt for AI Agents
In packages/ui/src/elements/phone-input.tsx around lines 6 to 7, the
SUPPORTED_COUNTRIES constant is declared but unused; either remove it or apply
it to validate the defaultCountry prop. Fix by using SUPPORTED_COUNTRIES to
check incoming defaultCountry (e.g., if
(!SUPPORTED_COUNTRIES.includes(defaultCountry)) log/warn or throw and fall back
to a safe default such as 'GB' or the first entry), update the component prop
handling to perform this validation before rendering, and remove the constant
entirely if you prefer not to enforce validation. Ensure the validation
preserves types (CountryCode) and add a unit or runtime test to cover the
fallback behavior.


export type PhoneInputProps = Omit<InputProps, 'onChange' | 'value'> & {
defaultCountry?: CountryCode;
value?: string;
onChange?: (value: string, isValid: boolean) => void;
onValidationChange?: (isValid: boolean) => void;
};

export type PhoneInputOwnProps = PhoneInputProps & { ref?: React.Ref<HTMLInputElement> };

const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputOwnProps>(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a Storybook file for this component so it can actually be tested, once it's done I will check how it works

({ 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<HTMLInputElement>) => {
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);
};
Comment on lines +53 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Race condition in validation status.

The isValid variable passed to onChange at line 61 is stale—it holds the value from the previous render, not the newly computed validation status from formatPhoneNumber. Because setIsValid is asynchronous, the updated value won't be available until the next render, causing onChange to receive outdated validation information.

Example:

  1. User types "5" → isValid is false, onChange("5", false)
  2. User types "500500500" → formatPhoneNumber sets isValid to true, but onChange("500500500", false) is called with the old false

This affects all three components in this file.

Solution: Return the validation status from formatPhoneNumber instead of relying on state:

 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;
+      return { formatted, valid };
     } catch (error) {
       setIsValid(false);
       onValidationChange?.(false);
-      return input;
+      return { formatted: input, valid: false };
     }
   },
   [defaultCountry, onValidationChange]
 );

 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
   const inputValue = e.target.value;
   const sanitized = inputValue.replace(/[^0-9+\s()-]/g, '');
-  const formatted = formatPhoneNumber(sanitized);
+  const { formatted, valid } = formatPhoneNumber(sanitized);
   setDisplayValue(formatted);
-  onChange?.(formatted, isValid);
+  onChange?.(formatted, valid);
 };

Apply the same fix to handlePaste and to the other two components (PhoneInputWithLabel, PhoneInputWithDetails).

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/ui/src/elements/phone-input.tsx around lines 53 to 62, the handler
passes the stale isValid state to onChange; change formatPhoneNumber to return
both the formatted value and its validation boolean, then in handleChange: call
formatPhoneNumber to get (formatted, valid), setDisplayValue(formatted),
setIsValid(valid), and call onChange?.(formatted, valid); apply the same pattern
to handlePaste and to the other two components (PhoneInputWithLabel,
PhoneInputWithDetails) so they all use the returned validation result instead of
the old state.


const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
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 (
<Input
{...props}
ref={ref}
type="tel"
value={displayValue}
onChange={handleChange}
onPaste={handlePaste}
placeholder="+48 500 500 500"
/>
);
}
);

PhoneInput.displayName = 'PhoneInput';

export type PhoneInputWithLabelProps = Omit<InputWithDetailsProps, 'onChange' | 'value'> & {
defaultCountry?: CountryCode;
value?: string;
onChange?: (value: string, isValid: boolean) => void;
onValidationChange?: (isValid: boolean) => void;
};
Comment on lines +89 to +94
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incorrect base type for PhoneInputWithLabel.

PhoneInputWithLabelProps extends InputWithDetailsProps, which includes caption and errorMessage props. However, the PhoneInputWithLabel component renders InputWithLabel (line 146) and never uses these props. This type mismatch allows consumers to pass props that will be silently ignored.

According to the relevant code snippets, InputWithDetailsProps = InputWithLabelProps & { caption, errorMessage }. Use the appropriate base type:

-export type PhoneInputWithLabelProps = Omit<InputWithDetailsProps, 'onChange' | 'value'> & {
+export type PhoneInputWithLabelProps = Omit<InputWithLabelProps, 'onChange' | 'value'> & {
   defaultCountry?: CountryCode;
   value?: string;
   onChange?: (value: string, isValid: boolean) => void;
   onValidationChange?: (isValid: boolean) => void;
 };

You'll also need to import InputWithLabelProps from ./input on line 3.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/ui/src/elements/phone-input.tsx around lines 89 to 94, the
PhoneInputWithLabelProps incorrectly extends InputWithDetailsProps (which
includes caption and errorMessage that PhoneInputWithLabel never uses); change
the base type to Omit<InputWithLabelProps, 'onChange' | 'value'> so the props
reflect InputWithLabel usage, update the generic additions (defaultCountry?,
value?, onChange?, onValidationChange?) accordingly, and add an import for
InputWithLabelProps from './input' at the top (line 3).


const PhoneInputWithLabel = React.forwardRef<HTMLInputElement, PhoneInputWithLabelProps>(
({ 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<InputWithLabel
{...props}
ref={ref}
type="tel"
value={displayValue}
onChange={handleChange}
onPaste={handlePaste}
placeholder="+48 500 500 500"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

placeholder CAN NOT be hardcoded

/>
);
}
);

PhoneInputWithLabel.displayName = 'PhoneInputWithLabel';

export type PhoneInputWithDetailsProps = Omit<InputWithDetailsProps, 'onChange' | 'value'> & {
defaultCountry?: CountryCode;
value?: string;
onChange?: (value: string, isValid: boolean) => void;
onValidationChange?: (isValid: boolean) => void;
};

const PhoneInputWithDetails = React.forwardRef<HTMLInputElement, PhoneInputWithDetailsProps>(
({ 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<InputWithDetails
{...props}
ref={ref}
type="tel"
value={displayValue}
onChange={handleChange}
onPaste={handlePaste}
placeholder="+48 500 500 500"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, please remove hardcoded placeholder

caption={caption}
errorMessage={errorMessage}
/>
);
}
);

PhoneInputWithDetails.displayName = 'PhoneInputWithDetails';
Comment on lines +18 to +233
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Extract shared logic to eliminate code duplication.

All three components (PhoneInput, PhoneInputWithLabel, PhoneInputWithDetails) contain nearly identical logic (~150 lines duplicated):

  • State management (displayValue, isValid)
  • useEffect for prop synchronization
  • formatPhoneNumber callback
  • handleChange handler
  • handlePaste handler

This violates DRY principles and triples the maintenance burden.

Recommended approach: Extract the shared logic into a custom hook:

function usePhoneInput(
  value: string,
  defaultCountry: CountryCode,
  onChange?: (value: string, isValid: boolean) => void,
  onValidationChange?: (isValid: boolean) => void
) {
  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 { formatted: '', valid: false };
      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, valid };
      } catch (error) {
        setIsValid(false);
        onValidationChange?.(false);
        return { formatted: input, valid: false };
      }
    },
    [defaultCountry, onValidationChange]
  );

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const inputValue = e.target.value;
    const sanitized = inputValue.replace(/[^0-9+\s()-]/g, '');
    const { formatted, valid } = formatPhoneNumber(sanitized);
    setDisplayValue(formatted);
    onChange?.(formatted, valid);
  };

  const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
    e.preventDefault();
    const pastedText = e.clipboardData.getData('text');
    const sanitized = pastedText.replace(/[^0-9+\s()-]/g, '');
    const { formatted, valid } = formatPhoneNumber(sanitized);
    setDisplayValue(formatted);
    onChange?.(formatted, valid);
  };

  return {
    displayValue,
    isValid,
    handleChange,
    handlePaste,
  };
}

Then simplify each component:

const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputOwnProps>(
  ({ defaultCountry = 'PL', value = '', onChange, onValidationChange, ...props }, ref) => {
    const { displayValue, handleChange, handlePaste } = usePhoneInput(
      value,
      defaultCountry,
      onChange,
      onValidationChange
    );

    return (
      <Input
        {...props}
        ref={ref}
        type="tel"
        value={displayValue}
        onChange={handleChange}
        onPaste={handlePaste}
        placeholder="+48 500 500 500"
      />
    );
  }
);
🤖 Prompt for AI Agents
In packages/ui/src/elements/phone-input.tsx around lines 18 to 233, the three
components duplicate the same state, effect, formatting, change and paste
handlers; extract that shared logic into a custom hook (e.g. usePhoneInput) that
accepts (value, defaultCountry, onChange, onValidationChange), manages
displayValue and isValid, synchronizes value via useEffect, exposes a
formatPhoneNumber that returns {formatted, valid}, and provides handleChange and
handlePaste which sanitize input, call formatPhoneNumber, update displayValue
and call onChange with the current valid flag; then refactor PhoneInput,
PhoneInputWithLabel and PhoneInputWithDetails to call the hook and use the
returned displayValue, handleChange and handlePaste (and use the returned valid
when invoking onChange) to remove the duplicated code.


export { PhoneInput, PhoneInputWithLabel, PhoneInputWithDetails };