Files
dataroom/components/ui/phone-input.tsx
2025-08-04 10:26:00 +02:00

142 lines
4.6 KiB
TypeScript

import * as React from "react";
import { E164Number } from "libphonenumber-js";
import { ChevronDownIcon, PhoneIcon } from "lucide-react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import { cn } from "@/lib/utils";
import { Input, InputProps } from "@/components/ui/input";
type PhoneInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"onChange" | "value"
> &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn("shadow-xs flex rounded-md", className)}
international
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.((value || "") as E164Number)}
{...props}
/>
);
},
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => (
<Input
data-slot="phone-input"
className={cn(
"-ms-px rounded-s-none border-0 bg-transparent py-1.5 shadow-sm ring-1 ring-inset ring-gray-600 focus:ring-2 focus:ring-inset focus:ring-gray-300 focus-visible:z-10",
className,
)}
style={
{
backgroundColor: "var(--phone-input-bg)",
color: "var(--phone-input-color)",
} as React.CSSProperties
}
{...props}
ref={ref}
/>
),
);
InputComponent.displayName = "InputComponent";
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
onChange: (value: RPNInput.Country) => void;
options: { label: string; value: RPNInput.Country | undefined }[];
};
const CountrySelect = ({
disabled,
value,
onChange,
options,
}: CountrySelectProps) => {
const handleSelect = (event: React.ChangeEvent<HTMLSelectElement>) => {
onChange(event.target.value as RPNInput.Country);
};
return (
<div
className="has-aria-invalid:border-destructive/60 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-disabled:pointer-events-none has-disabled:opacity-50 relative inline-flex h-9 items-center self-stretch rounded-s-md border-0 bg-transparent py-2 pe-2 ps-3 text-muted-foreground shadow-sm outline-none ring-1 ring-inset ring-gray-600 transition-[color,box-shadow] focus-within:z-10 focus-within:ring-2 focus-within:ring-inset focus-within:ring-gray-300 hover:text-foreground"
style={
{
backgroundColor: "var(--phone-input-bg)",
color: "var(--phone-input-color)",
} as React.CSSProperties
}
>
<div className="inline-flex items-center gap-1" aria-hidden="true">
<FlagComponent country={value} countryName={value} aria-hidden="true" />
<span className="text-muted-foreground/80">
<ChevronDownIcon size={16} aria-hidden="true" />
</span>
</div>
<select
disabled={disabled}
value={value}
onChange={handleSelect}
className="absolute inset-0 text-sm opacity-0"
aria-label="Select country"
>
<option key="default" value="">
Select a country
</option>
{options
.filter((x) => x.value)
.map((option, i) => (
<option key={option.value ?? `empty-${i}`} value={option.value}>
{option.label}{" "}
{option.value &&
`+${RPNInput.getCountryCallingCode(option.value)}`}
</option>
))}
</select>
</div>
);
};
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];
return (
<span className="w-5 overflow-hidden rounded-sm">
{Flag ? (
<Flag title={countryName} />
) : (
<PhoneIcon size={16} aria-hidden="true" />
)}
</span>
);
};
FlagComponent.displayName = "FlagComponent";
export { PhoneInput };