Phone Input
An accessible phone input component with automatic country detection and international phone number formatting.
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
export function PhoneInputDemo() {
return (
<PhoneInput>
<PhoneInputCountrySelect />
<PhoneInputField placeholder="12345667777" />
</PhoneInput>
);
}Installation
CLI
npx shadcn@latest add @diceui/phone-inputManual
Install the following dependencies:
npm install @radix-ui/react-slot lucide-reactCopy and paste the refs composition utilities into your lib/compose-refs.ts file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };Copy and paste the visually hidden input component into your components/visually-hidden-input.tsx file.
"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };Copy and paste the following hooks into your hooks directory.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };Copy and paste the following code into your project.
"use client";
import { Check, ChevronDown } from "lucide-react";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "PhoneInput";
const COUNTRY_SELECT_NAME = "PhoneInputCountrySelect";
const FIELD_NAME = "PhoneInputField";
/**
* @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js
* @format [iso2, dialCode]
*/
const COUNTRY_DATA: [string, string][] = [
["af", "93"],
["ax", "358"],
["al", "355"],
["dz", "213"],
["as", "1684"],
["ad", "376"],
["ao", "244"],
["ai", "1264"],
["ag", "1268"],
["ar", "54"],
["am", "374"],
["aw", "297"],
["au", "61"],
["at", "43"],
["az", "994"],
["bs", "1242"],
["bh", "973"],
["bd", "880"],
["bb", "1246"],
["by", "375"],
["be", "32"],
["bz", "501"],
["bj", "229"],
["bm", "1441"],
["bt", "975"],
["bo", "591"],
["ba", "387"],
["bw", "267"],
["br", "55"],
["io", "246"],
["vg", "1284"],
["bn", "673"],
["bg", "359"],
["bf", "226"],
["bi", "257"],
["kh", "855"],
["cm", "237"],
["ca", "1"],
["cv", "238"],
["bq", "599"],
["ky", "1345"],
["cf", "236"],
["td", "235"],
["cl", "56"],
["cn", "86"],
["co", "57"],
["km", "269"],
["cd", "243"],
["cg", "242"],
["ck", "682"],
["cr", "506"],
["ci", "225"],
["hr", "385"],
["cu", "53"],
["cw", "599"],
["cy", "357"],
["cz", "420"],
["dk", "45"],
["dj", "253"],
["dm", "1767"],
["do", "1"],
["ec", "593"],
["eg", "20"],
["sv", "503"],
["gq", "240"],
["er", "291"],
["ee", "372"],
["et", "251"],
["fk", "500"],
["fo", "298"],
["fj", "679"],
["fi", "358"],
["fr", "33"],
["gf", "594"],
["pf", "689"],
["ga", "241"],
["gm", "220"],
["ge", "995"],
["de", "49"],
["gh", "233"],
["gi", "350"],
["gr", "30"],
["gl", "299"],
["gd", "1473"],
["gp", "590"],
["gu", "1671"],
["gt", "502"],
["gg", "44"],
["gn", "224"],
["gw", "245"],
["gy", "592"],
["ht", "509"],
["hn", "504"],
["hk", "852"],
["hu", "36"],
["is", "354"],
["in", "91"],
["id", "62"],
["ir", "98"],
["iq", "964"],
["ie", "353"],
["im", "44"],
["il", "972"],
["it", "39"],
["jm", "1876"],
["jp", "81"],
["je", "44"],
["jo", "962"],
["kz", "7"],
["ke", "254"],
["ki", "686"],
["xk", "383"],
["kw", "965"],
["kg", "996"],
["la", "856"],
["lv", "371"],
["lb", "961"],
["ls", "266"],
["lr", "231"],
["ly", "218"],
["li", "423"],
["lt", "370"],
["lu", "352"],
["mo", "853"],
["mk", "389"],
["mg", "261"],
["mw", "265"],
["my", "60"],
["mv", "960"],
["ml", "223"],
["mt", "356"],
["mh", "692"],
["mq", "596"],
["mr", "222"],
["mu", "230"],
["mx", "52"],
["fm", "691"],
["md", "373"],
["mc", "377"],
["mn", "976"],
["me", "382"],
["ms", "1664"],
["ma", "212"],
["mz", "258"],
["mm", "95"],
["na", "264"],
["nr", "674"],
["np", "977"],
["nl", "31"],
["nc", "687"],
["nz", "64"],
["ni", "505"],
["ne", "227"],
["ng", "234"],
["nu", "683"],
["nf", "672"],
["kp", "850"],
["mp", "1670"],
["no", "47"],
["om", "968"],
["pk", "92"],
["pw", "680"],
["ps", "970"],
["pa", "507"],
["pg", "675"],
["py", "595"],
["pe", "51"],
["ph", "63"],
["pl", "48"],
["pt", "351"],
["pr", "1"],
["qa", "974"],
["re", "262"],
["ro", "40"],
["ru", "7"],
["rw", "250"],
["bl", "590"],
["sh", "290"],
["kn", "1869"],
["lc", "1758"],
["mf", "590"],
["pm", "508"],
["vc", "1784"],
["ws", "685"],
["sm", "378"],
["st", "239"],
["sa", "966"],
["sn", "221"],
["rs", "381"],
["sc", "248"],
["sl", "232"],
["sg", "65"],
["sx", "1721"],
["sk", "421"],
["si", "386"],
["sb", "677"],
["so", "252"],
["za", "27"],
["kr", "82"],
["ss", "211"],
["es", "34"],
["lk", "94"],
["sd", "249"],
["sr", "597"],
["sz", "268"],
["se", "46"],
["ch", "41"],
["sy", "963"],
["tw", "886"],
["tj", "992"],
["tz", "255"],
["th", "66"],
["tl", "670"],
["tg", "228"],
["tk", "690"],
["to", "676"],
["tt", "1868"],
["tn", "216"],
["tr", "90"],
["tm", "993"],
["tc", "1649"],
["tv", "688"],
["vi", "1340"],
["ug", "256"],
["ua", "380"],
["ae", "971"],
["gb", "44"],
["us", "1"],
["uy", "598"],
["uz", "998"],
["vu", "678"],
["va", "39"],
["ve", "58"],
["vn", "84"],
["wf", "681"],
["eh", "212"],
["ye", "967"],
["zm", "260"],
["zw", "263"],
];
interface Country {
code: string;
name: string;
dialCode: string;
flag?: string;
}
function getCountryName(countryCode: string, locale = "en"): string {
try {
const regionNames = new Intl.DisplayNames([locale], { type: "region" });
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
}
function getFlagEmoji(countryCode: string): string {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountries(): Country[] {
return COUNTRY_DATA.map(([iso2, dialCode]): Country => {
const code = iso2.toUpperCase();
return {
code,
name: getCountryName(code),
dialCode: `+${dialCode}`,
flag: getFlagEmoji(code),
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
function detectCountryFromNumber(
value: string,
countries: Country[],
): Country | undefined {
if (!value || !value.startsWith("+")) return undefined;
const digits = value.slice(1).replace(/\D/g, "");
if (!digits) return undefined;
const sorted = [...countries].sort(
(a, b) => b.dialCode.length - a.dialCode.length,
);
const matches: Country[] = [];
for (const country of sorted) {
const dialCode = country.dialCode.slice(1);
if (digits.startsWith(dialCode)) {
matches.push(country);
}
}
if (matches.length === 0) return undefined;
if (matches.length > 1 && matches[0]?.dialCode === "+1") {
const usCountry = matches.find((c) => c.code === "US");
if (usCountry) return usCountry;
}
return matches[0];
}
function formatPhoneNumber(value: string, countries: Country[]): string {
if (!value) return "";
const normalized = value.startsWith("+") ? value : `+${value}`;
const digits = normalized.slice(1).replace(/\D/g, "");
if (!digits) return "+";
const detected = detectCountryFromNumber(`+${digits}`, countries);
const dialCodeLength = detected
? detected.dialCode.slice(1).length
: Math.min(digits.length, 3);
const countryCode = digits.slice(0, dialCodeLength);
const rest = digits.slice(dialCodeLength);
let formatted = `+${countryCode}`;
if (rest) {
formatted += " ";
for (let i = 0; i < rest.length; i++) {
if (i > 0 && i % 3 === 0) {
formatted += " ";
}
formatted += rest[i];
}
}
return formatted;
}
type RootElement = React.ComponentRef<typeof PhoneInput>;
interface StoreState {
value: string;
country: string;
open: boolean;
startsWithPlus: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface PhoneInputContextValue {
rootId: string;
countries: Country[];
placeholder: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const PhoneInputContext = React.createContext<PhoneInputContextValue | null>(
null,
);
function usePhoneInputContext(consumerName: string) {
const context = React.useContext(PhoneInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface PhoneInputProps extends React.ComponentProps<"div"> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
defaultCountry?: string;
country?: string;
onCountryChange?: (country: string) => void;
countries?: Country[];
name?: string;
placeholder?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag?: boolean;
}
function PhoneInput(props: PhoneInputProps) {
const {
value: valueProp,
defaultValue,
defaultCountry,
country: countryProp,
onValueChange,
onCountryChange,
countries = getCountries(),
name,
placeholder = "Enter phone number",
asChild,
disabled,
required,
readOnly,
invalid,
showFlag = true,
className,
id,
ref,
...rootProps
} = props;
const instanceId = React.useId();
const rootId = id ?? instanceId;
const inputRef = React.useRef<HTMLInputElement>(null);
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => {
const initialValue = valueProp ?? defaultValue ?? "";
const initialCountry = countryProp ?? defaultCountry ?? "";
return {
value: initialValue,
country: initialCountry,
open: false,
startsWithPlus: initialValue.startsWith("+"),
};
});
const propsRef = useAsRef({
onValueChange,
onCountryChange,
});
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "value" && typeof value === "string") {
stateRef.current.value = value;
propsRef.current.onValueChange?.(value);
} else if (key === "country" && typeof value === "string") {
stateRef.current.country = value;
propsRef.current.onCountryChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const value = useStore((state) => state.value, store);
const country = useStore((state) => state.country, store);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp]);
useIsomorphicLayoutEffect(() => {
if (countryProp !== undefined) {
store.setState("country", countryProp);
}
}, [countryProp]);
const startsWithPlus = useStore((state) => state.startsWithPlus, store);
React.useEffect(() => {
if (!value) return;
const digits = value.slice(1).replace(/\D/g, "");
const shouldDetect = startsWithPlus || digits.length >= 10;
if (!shouldDetect) return;
const detected = detectCountryFromNumber(value, countries);
if (detected && detected.code !== country) {
store.setState("country", detected.code);
}
}, [value, countries, country, store, startsWithPlus]);
const contextValue = React.useMemo<PhoneInputContextValue>(
() => ({
rootId,
countries,
placeholder,
disabled,
readOnly,
required,
invalid,
showFlag,
inputRef,
}),
[
rootId,
countries,
placeholder,
disabled,
required,
readOnly,
invalid,
showFlag,
],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<StoreContext.Provider value={store}>
<PhoneInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
data-slot="phone-input"
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
id={rootId}
{...rootProps}
ref={composedRef}
className={cn(
"relative flex h-10 w-full items-center rounded-md border border-input bg-background transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
/>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</PhoneInputContext.Provider>
</StoreContext.Provider>
);
}
interface PhoneInputCountrySelectProps
extends React.ComponentProps<typeof Popover>,
Pick<
React.ComponentProps<typeof PopoverTrigger>,
"disabled" | "className"
> {}
function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) {
const {
disabled: disabledProp,
className,
children,
onOpenChange: onOpenChangeProp,
...popoverProps
} = props;
const { countries, inputRef, disabled, showFlag } =
usePhoneInputContext(COUNTRY_SELECT_NAME);
const store = useStoreContext(COUNTRY_SELECT_NAME);
const country = useStore((state) => state.country);
const open = useStore((state) => state.open);
const isDisabled = disabledProp || disabled;
const countryContext = countries.find((c) => c.code === country);
const onOpenChange = React.useCallback(
(open: boolean) => {
store.setState("open", open);
onOpenChangeProp?.(open);
},
[store, onOpenChangeProp],
);
return (
<Popover open={open} onOpenChange={onOpenChange} {...popoverProps}>
<PopoverTrigger
data-slot="phone-input-country-select"
disabled={isDisabled}
className={cn(
"flex h-full shrink-0 items-center gap-2 rounded-l-md border-input border-r bg-transparent px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
>
{!countryContext ? (
<div className="h-4 w-6 rounded bg-muted/50" />
) : (
showFlag &&
countryContext.flag && (
<div className="w-6 text-lg leading-none">
{countryContext.flag}
</div>
)
)}
<ChevronDown className="size-4 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem
key={c.code}
value={`${c.name} ${c.dialCode} ${c.code}`}
onSelect={() => {
store.setState("country", c.code);
store.setState("open", false);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
{showFlag && c.flag && (
<span className="text-lg">{c.flag}</span>
)}
<span className="flex-1">{c.name}</span>
<span className="text-muted-foreground">{c.dialCode}</span>
<Check
className={cn(
"size-4",
country === c.code ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function PhoneInputField(props: React.ComponentProps<"input">) {
const {
onChange: onChangeProp,
className,
disabled: disabledProp,
readOnly: readOnlyProp,
required: requiredProp,
ref,
...inputProps
} = props;
const {
inputRef,
disabled,
invalid,
readOnly,
required,
placeholder,
countries,
} = usePhoneInputContext(FIELD_NAME);
const store = useStoreContext(FIELD_NAME);
const value = useStore((state) => state.value);
const composedRef = useComposedRefs(ref, inputRef);
const onChangeRef = useAsRef(onChangeProp);
const isDisabled = disabledProp || disabled;
const isReadOnly = readOnlyProp || readOnly;
const isRequired = requiredProp || required;
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled || isReadOnly) return;
onChangeRef.current?.(event);
if (event.defaultPrevented) return;
const inputValue = event.target.value;
const startsWithPlus = inputValue.startsWith("+");
const digits = inputValue.replace(/\D/g, "");
const newValue = digits ? `+${digits}` : startsWithPlus ? "+" : "";
store.setState("startsWithPlus", startsWithPlus);
store.setState("value", newValue);
},
[store, onChangeRef, isDisabled, isReadOnly],
);
const displayValue = React.useMemo(() => {
return formatPhoneNumber(value, countries);
}, [value, countries]);
return (
<Input
type="tel"
inputMode="tel"
aria-required={isRequired}
aria-invalid={invalid}
data-slot="phone-input-field"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...inputProps}
ref={composedRef}
className={cn(
"h-full flex-1 rounded-r-md rounded-l-none border-0 bg-transparent shadow-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:bg-transparent aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-transparent dark:aria-invalid:ring-destructive/40 dark:disabled:bg-transparent",
className,
)}
placeholder={placeholder}
value={displayValue}
onChange={onChange}
/>
);
}
export {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
//
useStore as usePhoneInput,
//
type PhoneInputProps,
};"use client";
import { Check, ChevronDown } from "lucide-react";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "PhoneInput";
const COUNTRY_SELECT_NAME = "PhoneInputCountrySelect";
const FIELD_NAME = "PhoneInputField";
/**
* @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js
* @format [iso2, dialCode]
*/
const COUNTRY_DATA: [string, string][] = [
["af", "93"],
["ax", "358"],
["al", "355"],
["dz", "213"],
["as", "1684"],
["ad", "376"],
["ao", "244"],
["ai", "1264"],
["ag", "1268"],
["ar", "54"],
["am", "374"],
["aw", "297"],
["au", "61"],
["at", "43"],
["az", "994"],
["bs", "1242"],
["bh", "973"],
["bd", "880"],
["bb", "1246"],
["by", "375"],
["be", "32"],
["bz", "501"],
["bj", "229"],
["bm", "1441"],
["bt", "975"],
["bo", "591"],
["ba", "387"],
["bw", "267"],
["br", "55"],
["io", "246"],
["vg", "1284"],
["bn", "673"],
["bg", "359"],
["bf", "226"],
["bi", "257"],
["kh", "855"],
["cm", "237"],
["ca", "1"],
["cv", "238"],
["bq", "599"],
["ky", "1345"],
["cf", "236"],
["td", "235"],
["cl", "56"],
["cn", "86"],
["co", "57"],
["km", "269"],
["cd", "243"],
["cg", "242"],
["ck", "682"],
["cr", "506"],
["ci", "225"],
["hr", "385"],
["cu", "53"],
["cw", "599"],
["cy", "357"],
["cz", "420"],
["dk", "45"],
["dj", "253"],
["dm", "1767"],
["do", "1"],
["ec", "593"],
["eg", "20"],
["sv", "503"],
["gq", "240"],
["er", "291"],
["ee", "372"],
["et", "251"],
["fk", "500"],
["fo", "298"],
["fj", "679"],
["fi", "358"],
["fr", "33"],
["gf", "594"],
["pf", "689"],
["ga", "241"],
["gm", "220"],
["ge", "995"],
["de", "49"],
["gh", "233"],
["gi", "350"],
["gr", "30"],
["gl", "299"],
["gd", "1473"],
["gp", "590"],
["gu", "1671"],
["gt", "502"],
["gg", "44"],
["gn", "224"],
["gw", "245"],
["gy", "592"],
["ht", "509"],
["hn", "504"],
["hk", "852"],
["hu", "36"],
["is", "354"],
["in", "91"],
["id", "62"],
["ir", "98"],
["iq", "964"],
["ie", "353"],
["im", "44"],
["il", "972"],
["it", "39"],
["jm", "1876"],
["jp", "81"],
["je", "44"],
["jo", "962"],
["kz", "7"],
["ke", "254"],
["ki", "686"],
["xk", "383"],
["kw", "965"],
["kg", "996"],
["la", "856"],
["lv", "371"],
["lb", "961"],
["ls", "266"],
["lr", "231"],
["ly", "218"],
["li", "423"],
["lt", "370"],
["lu", "352"],
["mo", "853"],
["mk", "389"],
["mg", "261"],
["mw", "265"],
["my", "60"],
["mv", "960"],
["ml", "223"],
["mt", "356"],
["mh", "692"],
["mq", "596"],
["mr", "222"],
["mu", "230"],
["mx", "52"],
["fm", "691"],
["md", "373"],
["mc", "377"],
["mn", "976"],
["me", "382"],
["ms", "1664"],
["ma", "212"],
["mz", "258"],
["mm", "95"],
["na", "264"],
["nr", "674"],
["np", "977"],
["nl", "31"],
["nc", "687"],
["nz", "64"],
["ni", "505"],
["ne", "227"],
["ng", "234"],
["nu", "683"],
["nf", "672"],
["kp", "850"],
["mp", "1670"],
["no", "47"],
["om", "968"],
["pk", "92"],
["pw", "680"],
["ps", "970"],
["pa", "507"],
["pg", "675"],
["py", "595"],
["pe", "51"],
["ph", "63"],
["pl", "48"],
["pt", "351"],
["pr", "1"],
["qa", "974"],
["re", "262"],
["ro", "40"],
["ru", "7"],
["rw", "250"],
["bl", "590"],
["sh", "290"],
["kn", "1869"],
["lc", "1758"],
["mf", "590"],
["pm", "508"],
["vc", "1784"],
["ws", "685"],
["sm", "378"],
["st", "239"],
["sa", "966"],
["sn", "221"],
["rs", "381"],
["sc", "248"],
["sl", "232"],
["sg", "65"],
["sx", "1721"],
["sk", "421"],
["si", "386"],
["sb", "677"],
["so", "252"],
["za", "27"],
["kr", "82"],
["ss", "211"],
["es", "34"],
["lk", "94"],
["sd", "249"],
["sr", "597"],
["sz", "268"],
["se", "46"],
["ch", "41"],
["sy", "963"],
["tw", "886"],
["tj", "992"],
["tz", "255"],
["th", "66"],
["tl", "670"],
["tg", "228"],
["tk", "690"],
["to", "676"],
["tt", "1868"],
["tn", "216"],
["tr", "90"],
["tm", "993"],
["tc", "1649"],
["tv", "688"],
["vi", "1340"],
["ug", "256"],
["ua", "380"],
["ae", "971"],
["gb", "44"],
["us", "1"],
["uy", "598"],
["uz", "998"],
["vu", "678"],
["va", "39"],
["ve", "58"],
["vn", "84"],
["wf", "681"],
["eh", "212"],
["ye", "967"],
["zm", "260"],
["zw", "263"],
];
interface Country {
code: string;
name: string;
dialCode: string;
flag?: string;
}
function getCountryName(countryCode: string, locale = "en"): string {
try {
const regionNames = new Intl.DisplayNames([locale], { type: "region" });
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
}
function getFlagEmoji(countryCode: string): string {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountries(): Country[] {
return COUNTRY_DATA.map(([iso2, dialCode]): Country => {
const code = iso2.toUpperCase();
return {
code,
name: getCountryName(code),
dialCode: `+${dialCode}`,
flag: getFlagEmoji(code),
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
function detectCountryFromNumber(
value: string,
countries: Country[],
): Country | undefined {
if (!value || !value.startsWith("+")) return undefined;
const digits = value.slice(1).replace(/\D/g, "");
if (!digits) return undefined;
const sorted = [...countries].sort(
(a, b) => b.dialCode.length - a.dialCode.length,
);
const matches: Country[] = [];
for (const country of sorted) {
const dialCode = country.dialCode.slice(1);
if (digits.startsWith(dialCode)) {
matches.push(country);
}
}
if (matches.length === 0) return undefined;
if (matches.length > 1 && matches[0]?.dialCode === "+1") {
const usCountry = matches.find((c) => c.code === "US");
if (usCountry) return usCountry;
}
return matches[0];
}
function formatPhoneNumber(value: string, countries: Country[]): string {
if (!value) return "";
const normalized = value.startsWith("+") ? value : `+${value}`;
const digits = normalized.slice(1).replace(/\D/g, "");
if (!digits) return "+";
const detected = detectCountryFromNumber(`+${digits}`, countries);
const dialCodeLength = detected
? detected.dialCode.slice(1).length
: Math.min(digits.length, 3);
const countryCode = digits.slice(0, dialCodeLength);
const rest = digits.slice(dialCodeLength);
let formatted = `+${countryCode}`;
if (rest) {
formatted += " ";
for (let i = 0; i < rest.length; i++) {
if (i > 0 && i % 3 === 0) {
formatted += " ";
}
formatted += rest[i];
}
}
return formatted;
}
type RootElement = React.ComponentRef<typeof PhoneInput>;
interface StoreState {
value: string;
country: string;
open: boolean;
startsWithPlus: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface PhoneInputContextValue {
rootId: string;
countries: Country[];
placeholder: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const PhoneInputContext = React.createContext<PhoneInputContextValue | null>(
null,
);
function usePhoneInputContext(consumerName: string) {
const context = React.useContext(PhoneInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface PhoneInputProps extends React.ComponentProps<"div"> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
defaultCountry?: string;
country?: string;
onCountryChange?: (country: string) => void;
countries?: Country[];
name?: string;
placeholder?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag?: boolean;
}
function PhoneInput(props: PhoneInputProps) {
const {
value: valueProp,
defaultValue,
defaultCountry,
country: countryProp,
onValueChange,
onCountryChange,
countries = getCountries(),
name,
placeholder = "Enter phone number",
asChild,
disabled,
required,
readOnly,
invalid,
showFlag = true,
className,
id,
ref,
...rootProps
} = props;
const instanceId = React.useId();
const rootId = id ?? instanceId;
const inputRef = React.useRef<HTMLInputElement>(null);
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => {
const initialValue = valueProp ?? defaultValue ?? "";
const initialCountry = countryProp ?? defaultCountry ?? "";
return {
value: initialValue,
country: initialCountry,
open: false,
startsWithPlus: initialValue.startsWith("+"),
};
});
const propsRef = useAsRef({
onValueChange,
onCountryChange,
});
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "value" && typeof value === "string") {
stateRef.current.value = value;
propsRef.current.onValueChange?.(value);
} else if (key === "country" && typeof value === "string") {
stateRef.current.country = value;
propsRef.current.onCountryChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const value = useStore((state) => state.value, store);
const country = useStore((state) => state.country, store);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp]);
useIsomorphicLayoutEffect(() => {
if (countryProp !== undefined) {
store.setState("country", countryProp);
}
}, [countryProp]);
const startsWithPlus = useStore((state) => state.startsWithPlus, store);
React.useEffect(() => {
if (!value) return;
const digits = value.slice(1).replace(/\D/g, "");
const shouldDetect = startsWithPlus || digits.length >= 10;
if (!shouldDetect) return;
const detected = detectCountryFromNumber(value, countries);
if (detected && detected.code !== country) {
store.setState("country", detected.code);
}
}, [value, countries, country, store, startsWithPlus]);
const contextValue = React.useMemo<PhoneInputContextValue>(
() => ({
rootId,
countries,
placeholder,
disabled,
readOnly,
required,
invalid,
showFlag,
inputRef,
}),
[
rootId,
countries,
placeholder,
disabled,
required,
readOnly,
invalid,
showFlag,
],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<StoreContext.Provider value={store}>
<PhoneInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
data-slot="phone-input"
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
id={rootId}
{...rootProps}
ref={composedRef}
className={cn(
"relative flex h-10 w-full items-center rounded-md border border-input bg-background transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
/>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</PhoneInputContext.Provider>
</StoreContext.Provider>
);
}
interface PhoneInputCountrySelectProps
extends React.ComponentProps<typeof Popover>,
Pick<
React.ComponentProps<typeof PopoverTrigger>,
"disabled" | "className"
> {}
function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) {
const {
disabled: disabledProp,
className,
children,
onOpenChange: onOpenChangeProp,
...popoverProps
} = props;
const { countries, inputRef, disabled, showFlag } =
usePhoneInputContext(COUNTRY_SELECT_NAME);
const store = useStoreContext(COUNTRY_SELECT_NAME);
const country = useStore((state) => state.country);
const open = useStore((state) => state.open);
const isDisabled = disabledProp || disabled;
const countryContext = countries.find((c) => c.code === country);
const onOpenChange = React.useCallback(
(open: boolean) => {
store.setState("open", open);
onOpenChangeProp?.(open);
},
[store, onOpenChangeProp],
);
return (
<Popover open={open} onOpenChange={onOpenChange} {...popoverProps}>
<PopoverTrigger
data-slot="phone-input-country-select"
disabled={isDisabled}
className={cn(
"flex h-full shrink-0 items-center gap-2 rounded-l-md border-input border-r bg-transparent px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
>
{!countryContext ? (
<div className="h-4 w-6 rounded bg-muted/50" />
) : (
showFlag &&
countryContext.flag && (
<div className="w-6 text-lg leading-none">
{countryContext.flag}
</div>
)
)}
<ChevronDown className="size-4 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem
key={c.code}
value={`${c.name} ${c.dialCode} ${c.code}`}
onSelect={() => {
store.setState("country", c.code);
store.setState("open", false);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
{showFlag && c.flag && (
<span className="text-lg">{c.flag}</span>
)}
<span className="flex-1">{c.name}</span>
<span className="text-muted-foreground">{c.dialCode}</span>
<Check
className={cn(
"size-4",
country === c.code ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function PhoneInputField(props: React.ComponentProps<"input">) {
const {
onChange: onChangeProp,
className,
disabled: disabledProp,
readOnly: readOnlyProp,
required: requiredProp,
ref,
...inputProps
} = props;
const {
inputRef,
disabled,
invalid,
readOnly,
required,
placeholder,
countries,
} = usePhoneInputContext(FIELD_NAME);
const store = useStoreContext(FIELD_NAME);
const value = useStore((state) => state.value);
const composedRef = useComposedRefs(ref, inputRef);
const onChangeRef = useAsRef(onChangeProp);
const isDisabled = disabledProp || disabled;
const isReadOnly = readOnlyProp || readOnly;
const isRequired = requiredProp || required;
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled || isReadOnly) return;
onChangeRef.current?.(event);
if (event.defaultPrevented) return;
const inputValue = event.target.value;
const startsWithPlus = inputValue.startsWith("+");
const digits = inputValue.replace(/\D/g, "");
const newValue = digits ? `+${digits}` : startsWithPlus ? "+" : "";
store.setState("startsWithPlus", startsWithPlus);
store.setState("value", newValue);
},
[store, onChangeRef, isDisabled, isReadOnly],
);
const displayValue = React.useMemo(() => {
return formatPhoneNumber(value, countries);
}, [value, countries]);
return (
<Input
type="tel"
inputMode="tel"
aria-required={isRequired}
aria-invalid={invalid}
data-slot="phone-input-field"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...inputProps}
ref={composedRef}
className={cn(
"h-full flex-1 rounded-r-md rounded-l-none border-0 bg-transparent shadow-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:bg-transparent aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-transparent dark:aria-invalid:ring-destructive/40 dark:disabled:bg-transparent",
className,
)}
placeholder={placeholder}
value={displayValue}
onChange={onChange}
/>
);
}
export {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
//
useStore as usePhoneInput,
//
type PhoneInputProps,
};"use client";
import { Check, ChevronDown } from "lucide-react";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "PhoneInput";
const COUNTRY_SELECT_NAME = "PhoneInputCountrySelect";
const FIELD_NAME = "PhoneInputField";
/**
* @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js
* @format [iso2, dialCode]
*/
const COUNTRY_DATA: [string, string][] = [
["af", "93"],
["ax", "358"],
["al", "355"],
["dz", "213"],
["as", "1684"],
["ad", "376"],
["ao", "244"],
["ai", "1264"],
["ag", "1268"],
["ar", "54"],
["am", "374"],
["aw", "297"],
["au", "61"],
["at", "43"],
["az", "994"],
["bs", "1242"],
["bh", "973"],
["bd", "880"],
["bb", "1246"],
["by", "375"],
["be", "32"],
["bz", "501"],
["bj", "229"],
["bm", "1441"],
["bt", "975"],
["bo", "591"],
["ba", "387"],
["bw", "267"],
["br", "55"],
["io", "246"],
["vg", "1284"],
["bn", "673"],
["bg", "359"],
["bf", "226"],
["bi", "257"],
["kh", "855"],
["cm", "237"],
["ca", "1"],
["cv", "238"],
["bq", "599"],
["ky", "1345"],
["cf", "236"],
["td", "235"],
["cl", "56"],
["cn", "86"],
["co", "57"],
["km", "269"],
["cd", "243"],
["cg", "242"],
["ck", "682"],
["cr", "506"],
["ci", "225"],
["hr", "385"],
["cu", "53"],
["cw", "599"],
["cy", "357"],
["cz", "420"],
["dk", "45"],
["dj", "253"],
["dm", "1767"],
["do", "1"],
["ec", "593"],
["eg", "20"],
["sv", "503"],
["gq", "240"],
["er", "291"],
["ee", "372"],
["et", "251"],
["fk", "500"],
["fo", "298"],
["fj", "679"],
["fi", "358"],
["fr", "33"],
["gf", "594"],
["pf", "689"],
["ga", "241"],
["gm", "220"],
["ge", "995"],
["de", "49"],
["gh", "233"],
["gi", "350"],
["gr", "30"],
["gl", "299"],
["gd", "1473"],
["gp", "590"],
["gu", "1671"],
["gt", "502"],
["gg", "44"],
["gn", "224"],
["gw", "245"],
["gy", "592"],
["ht", "509"],
["hn", "504"],
["hk", "852"],
["hu", "36"],
["is", "354"],
["in", "91"],
["id", "62"],
["ir", "98"],
["iq", "964"],
["ie", "353"],
["im", "44"],
["il", "972"],
["it", "39"],
["jm", "1876"],
["jp", "81"],
["je", "44"],
["jo", "962"],
["kz", "7"],
["ke", "254"],
["ki", "686"],
["xk", "383"],
["kw", "965"],
["kg", "996"],
["la", "856"],
["lv", "371"],
["lb", "961"],
["ls", "266"],
["lr", "231"],
["ly", "218"],
["li", "423"],
["lt", "370"],
["lu", "352"],
["mo", "853"],
["mk", "389"],
["mg", "261"],
["mw", "265"],
["my", "60"],
["mv", "960"],
["ml", "223"],
["mt", "356"],
["mh", "692"],
["mq", "596"],
["mr", "222"],
["mu", "230"],
["mx", "52"],
["fm", "691"],
["md", "373"],
["mc", "377"],
["mn", "976"],
["me", "382"],
["ms", "1664"],
["ma", "212"],
["mz", "258"],
["mm", "95"],
["na", "264"],
["nr", "674"],
["np", "977"],
["nl", "31"],
["nc", "687"],
["nz", "64"],
["ni", "505"],
["ne", "227"],
["ng", "234"],
["nu", "683"],
["nf", "672"],
["kp", "850"],
["mp", "1670"],
["no", "47"],
["om", "968"],
["pk", "92"],
["pw", "680"],
["ps", "970"],
["pa", "507"],
["pg", "675"],
["py", "595"],
["pe", "51"],
["ph", "63"],
["pl", "48"],
["pt", "351"],
["pr", "1"],
["qa", "974"],
["re", "262"],
["ro", "40"],
["ru", "7"],
["rw", "250"],
["bl", "590"],
["sh", "290"],
["kn", "1869"],
["lc", "1758"],
["mf", "590"],
["pm", "508"],
["vc", "1784"],
["ws", "685"],
["sm", "378"],
["st", "239"],
["sa", "966"],
["sn", "221"],
["rs", "381"],
["sc", "248"],
["sl", "232"],
["sg", "65"],
["sx", "1721"],
["sk", "421"],
["si", "386"],
["sb", "677"],
["so", "252"],
["za", "27"],
["kr", "82"],
["ss", "211"],
["es", "34"],
["lk", "94"],
["sd", "249"],
["sr", "597"],
["sz", "268"],
["se", "46"],
["ch", "41"],
["sy", "963"],
["tw", "886"],
["tj", "992"],
["tz", "255"],
["th", "66"],
["tl", "670"],
["tg", "228"],
["tk", "690"],
["to", "676"],
["tt", "1868"],
["tn", "216"],
["tr", "90"],
["tm", "993"],
["tc", "1649"],
["tv", "688"],
["vi", "1340"],
["ug", "256"],
["ua", "380"],
["ae", "971"],
["gb", "44"],
["us", "1"],
["uy", "598"],
["uz", "998"],
["vu", "678"],
["va", "39"],
["ve", "58"],
["vn", "84"],
["wf", "681"],
["eh", "212"],
["ye", "967"],
["zm", "260"],
["zw", "263"],
];
interface Country {
code: string;
name: string;
dialCode: string;
flag?: string;
}
function getCountryName(countryCode: string, locale = "en"): string {
try {
const regionNames = new Intl.DisplayNames([locale], { type: "region" });
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
}
function getFlagEmoji(countryCode: string): string {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountries(): Country[] {
return COUNTRY_DATA.map(([iso2, dialCode]): Country => {
const code = iso2.toUpperCase();
return {
code,
name: getCountryName(code),
dialCode: `+${dialCode}`,
flag: getFlagEmoji(code),
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
function detectCountryFromNumber(
value: string,
countries: Country[],
): Country | undefined {
if (!value || !value.startsWith("+")) return undefined;
const digits = value.slice(1).replace(/\D/g, "");
if (!digits) return undefined;
const sorted = [...countries].sort(
(a, b) => b.dialCode.length - a.dialCode.length,
);
const matches: Country[] = [];
for (const country of sorted) {
const dialCode = country.dialCode.slice(1);
if (digits.startsWith(dialCode)) {
matches.push(country);
}
}
if (matches.length === 0) return undefined;
if (matches.length > 1 && matches[0]?.dialCode === "+1") {
const usCountry = matches.find((c) => c.code === "US");
if (usCountry) return usCountry;
}
return matches[0];
}
function formatPhoneNumber(value: string, countries: Country[]): string {
if (!value) return "";
const normalized = value.startsWith("+") ? value : `+${value}`;
const digits = normalized.slice(1).replace(/\D/g, "");
if (!digits) return "+";
const detected = detectCountryFromNumber(`+${digits}`, countries);
const dialCodeLength = detected
? detected.dialCode.slice(1).length
: Math.min(digits.length, 3);
const countryCode = digits.slice(0, dialCodeLength);
const rest = digits.slice(dialCodeLength);
let formatted = `+${countryCode}`;
if (rest) {
formatted += " ";
for (let i = 0; i < rest.length; i++) {
if (i > 0 && i % 3 === 0) {
formatted += " ";
}
formatted += rest[i];
}
}
return formatted;
}
type RootElement = React.ComponentRef<typeof PhoneInput>;
interface StoreState {
value: string;
country: string;
open: boolean;
startsWithPlus: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface PhoneInputContextValue {
rootId: string;
countries: Country[];
placeholder: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const PhoneInputContext = React.createContext<PhoneInputContextValue | null>(
null,
);
function usePhoneInputContext(consumerName: string) {
const context = React.useContext(PhoneInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface PhoneInputProps extends React.ComponentProps<"div"> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
defaultCountry?: string;
country?: string;
onCountryChange?: (country: string) => void;
countries?: Country[];
name?: string;
placeholder?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag?: boolean;
}
function PhoneInput(props: PhoneInputProps) {
const {
value: valueProp,
defaultValue,
defaultCountry,
country: countryProp,
onValueChange,
onCountryChange,
countries = getCountries(),
name,
placeholder = "Enter phone number",
asChild,
disabled,
required,
readOnly,
invalid,
showFlag = true,
className,
id,
ref,
...rootProps
} = props;
const instanceId = React.useId();
const rootId = id ?? instanceId;
const inputRef = React.useRef<HTMLInputElement>(null);
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => {
const initialValue = valueProp ?? defaultValue ?? "";
const initialCountry = countryProp ?? defaultCountry ?? "";
return {
value: initialValue,
country: initialCountry,
open: false,
startsWithPlus: initialValue.startsWith("+"),
};
});
const propsRef = useAsRef({
onValueChange,
onCountryChange,
});
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "value" && typeof value === "string") {
stateRef.current.value = value;
propsRef.current.onValueChange?.(value);
} else if (key === "country" && typeof value === "string") {
stateRef.current.country = value;
propsRef.current.onCountryChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const value = useStore((state) => state.value, store);
const country = useStore((state) => state.country, store);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp]);
useIsomorphicLayoutEffect(() => {
if (countryProp !== undefined) {
store.setState("country", countryProp);
}
}, [countryProp]);
const startsWithPlus = useStore((state) => state.startsWithPlus, store);
React.useEffect(() => {
if (!value) return;
const digits = value.slice(1).replace(/\D/g, "");
const shouldDetect = startsWithPlus || digits.length >= 10;
if (!shouldDetect) return;
const detected = detectCountryFromNumber(value, countries);
if (detected && detected.code !== country) {
store.setState("country", detected.code);
}
}, [value, countries, country, store, startsWithPlus]);
const contextValue = React.useMemo<PhoneInputContextValue>(
() => ({
rootId,
countries,
placeholder,
disabled,
readOnly,
required,
invalid,
showFlag,
inputRef,
}),
[
rootId,
countries,
placeholder,
disabled,
required,
readOnly,
invalid,
showFlag,
],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<StoreContext.Provider value={store}>
<PhoneInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
data-slot="phone-input"
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
id={rootId}
{...rootProps}
ref={composedRef}
className={cn(
"relative flex h-10 w-full items-center rounded-md border border-input bg-background transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
/>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</PhoneInputContext.Provider>
</StoreContext.Provider>
);
}
interface PhoneInputCountrySelectProps
extends React.ComponentProps<typeof Popover>,
Pick<
React.ComponentProps<typeof PopoverTrigger>,
"disabled" | "className"
> {}
function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) {
const {
disabled: disabledProp,
className,
children,
onOpenChange: onOpenChangeProp,
...popoverProps
} = props;
const { countries, inputRef, disabled, showFlag } =
usePhoneInputContext(COUNTRY_SELECT_NAME);
const store = useStoreContext(COUNTRY_SELECT_NAME);
const country = useStore((state) => state.country);
const open = useStore((state) => state.open);
const isDisabled = disabledProp || disabled;
const countryContext = countries.find((c) => c.code === country);
const onOpenChange = React.useCallback(
(open: boolean) => {
store.setState("open", open);
onOpenChangeProp?.(open);
},
[store, onOpenChangeProp],
);
return (
<Popover open={open} onOpenChange={onOpenChange} {...popoverProps}>
<PopoverTrigger
data-slot="phone-input-country-select"
disabled={isDisabled}
className={cn(
"flex h-full shrink-0 items-center gap-2 rounded-l-md border-input border-r bg-transparent px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
>
{!countryContext ? (
<div className="h-4 w-6 rounded bg-muted/50" />
) : (
showFlag &&
countryContext.flag && (
<div className="w-6 text-lg leading-none">
{countryContext.flag}
</div>
)
)}
<ChevronDown className="size-4 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem
key={c.code}
value={`${c.name} ${c.dialCode} ${c.code}`}
onSelect={() => {
store.setState("country", c.code);
store.setState("open", false);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
{showFlag && c.flag && (
<span className="text-lg">{c.flag}</span>
)}
<span className="flex-1">{c.name}</span>
<span className="text-muted-foreground">{c.dialCode}</span>
<Check
className={cn(
"size-4",
country === c.code ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function PhoneInputField(props: React.ComponentProps<"input">) {
const {
onChange: onChangeProp,
className,
disabled: disabledProp,
readOnly: readOnlyProp,
required: requiredProp,
ref,
...inputProps
} = props;
const {
inputRef,
disabled,
invalid,
readOnly,
required,
placeholder,
countries,
} = usePhoneInputContext(FIELD_NAME);
const store = useStoreContext(FIELD_NAME);
const value = useStore((state) => state.value);
const composedRef = useComposedRefs(ref, inputRef);
const onChangeRef = useAsRef(onChangeProp);
const isDisabled = disabledProp || disabled;
const isReadOnly = readOnlyProp || readOnly;
const isRequired = requiredProp || required;
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled || isReadOnly) return;
onChangeRef.current?.(event);
if (event.defaultPrevented) return;
const inputValue = event.target.value;
const startsWithPlus = inputValue.startsWith("+");
const digits = inputValue.replace(/\D/g, "");
const newValue = digits ? `+${digits}` : startsWithPlus ? "+" : "";
store.setState("startsWithPlus", startsWithPlus);
store.setState("value", newValue);
},
[store, onChangeRef, isDisabled, isReadOnly],
);
const displayValue = React.useMemo(() => {
return formatPhoneNumber(value, countries);
}, [value, countries]);
return (
<Input
type="tel"
inputMode="tel"
aria-required={isRequired}
aria-invalid={invalid}
data-slot="phone-input-field"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...inputProps}
ref={composedRef}
className={cn(
"h-full flex-1 rounded-r-md rounded-l-none border-0 bg-transparent shadow-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:bg-transparent aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-transparent dark:aria-invalid:ring-destructive/40 dark:disabled:bg-transparent",
className,
)}
placeholder={placeholder}
value={displayValue}
onChange={onChange}
/>
);
}
export {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
//
useStore as usePhoneInput,
//
type PhoneInputProps,
};"use client";
import { Check, ChevronDown } from "lucide-react";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "PhoneInput";
const COUNTRY_SELECT_NAME = "PhoneInputCountrySelect";
const FIELD_NAME = "PhoneInputField";
/**
* @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js
* @format [iso2, dialCode]
*/
const COUNTRY_DATA: [string, string][] = [
["af", "93"],
["ax", "358"],
["al", "355"],
["dz", "213"],
["as", "1684"],
["ad", "376"],
["ao", "244"],
["ai", "1264"],
["ag", "1268"],
["ar", "54"],
["am", "374"],
["aw", "297"],
["au", "61"],
["at", "43"],
["az", "994"],
["bs", "1242"],
["bh", "973"],
["bd", "880"],
["bb", "1246"],
["by", "375"],
["be", "32"],
["bz", "501"],
["bj", "229"],
["bm", "1441"],
["bt", "975"],
["bo", "591"],
["ba", "387"],
["bw", "267"],
["br", "55"],
["io", "246"],
["vg", "1284"],
["bn", "673"],
["bg", "359"],
["bf", "226"],
["bi", "257"],
["kh", "855"],
["cm", "237"],
["ca", "1"],
["cv", "238"],
["bq", "599"],
["ky", "1345"],
["cf", "236"],
["td", "235"],
["cl", "56"],
["cn", "86"],
["co", "57"],
["km", "269"],
["cd", "243"],
["cg", "242"],
["ck", "682"],
["cr", "506"],
["ci", "225"],
["hr", "385"],
["cu", "53"],
["cw", "599"],
["cy", "357"],
["cz", "420"],
["dk", "45"],
["dj", "253"],
["dm", "1767"],
["do", "1"],
["ec", "593"],
["eg", "20"],
["sv", "503"],
["gq", "240"],
["er", "291"],
["ee", "372"],
["et", "251"],
["fk", "500"],
["fo", "298"],
["fj", "679"],
["fi", "358"],
["fr", "33"],
["gf", "594"],
["pf", "689"],
["ga", "241"],
["gm", "220"],
["ge", "995"],
["de", "49"],
["gh", "233"],
["gi", "350"],
["gr", "30"],
["gl", "299"],
["gd", "1473"],
["gp", "590"],
["gu", "1671"],
["gt", "502"],
["gg", "44"],
["gn", "224"],
["gw", "245"],
["gy", "592"],
["ht", "509"],
["hn", "504"],
["hk", "852"],
["hu", "36"],
["is", "354"],
["in", "91"],
["id", "62"],
["ir", "98"],
["iq", "964"],
["ie", "353"],
["im", "44"],
["il", "972"],
["it", "39"],
["jm", "1876"],
["jp", "81"],
["je", "44"],
["jo", "962"],
["kz", "7"],
["ke", "254"],
["ki", "686"],
["xk", "383"],
["kw", "965"],
["kg", "996"],
["la", "856"],
["lv", "371"],
["lb", "961"],
["ls", "266"],
["lr", "231"],
["ly", "218"],
["li", "423"],
["lt", "370"],
["lu", "352"],
["mo", "853"],
["mk", "389"],
["mg", "261"],
["mw", "265"],
["my", "60"],
["mv", "960"],
["ml", "223"],
["mt", "356"],
["mh", "692"],
["mq", "596"],
["mr", "222"],
["mu", "230"],
["mx", "52"],
["fm", "691"],
["md", "373"],
["mc", "377"],
["mn", "976"],
["me", "382"],
["ms", "1664"],
["ma", "212"],
["mz", "258"],
["mm", "95"],
["na", "264"],
["nr", "674"],
["np", "977"],
["nl", "31"],
["nc", "687"],
["nz", "64"],
["ni", "505"],
["ne", "227"],
["ng", "234"],
["nu", "683"],
["nf", "672"],
["kp", "850"],
["mp", "1670"],
["no", "47"],
["om", "968"],
["pk", "92"],
["pw", "680"],
["ps", "970"],
["pa", "507"],
["pg", "675"],
["py", "595"],
["pe", "51"],
["ph", "63"],
["pl", "48"],
["pt", "351"],
["pr", "1"],
["qa", "974"],
["re", "262"],
["ro", "40"],
["ru", "7"],
["rw", "250"],
["bl", "590"],
["sh", "290"],
["kn", "1869"],
["lc", "1758"],
["mf", "590"],
["pm", "508"],
["vc", "1784"],
["ws", "685"],
["sm", "378"],
["st", "239"],
["sa", "966"],
["sn", "221"],
["rs", "381"],
["sc", "248"],
["sl", "232"],
["sg", "65"],
["sx", "1721"],
["sk", "421"],
["si", "386"],
["sb", "677"],
["so", "252"],
["za", "27"],
["kr", "82"],
["ss", "211"],
["es", "34"],
["lk", "94"],
["sd", "249"],
["sr", "597"],
["sz", "268"],
["se", "46"],
["ch", "41"],
["sy", "963"],
["tw", "886"],
["tj", "992"],
["tz", "255"],
["th", "66"],
["tl", "670"],
["tg", "228"],
["tk", "690"],
["to", "676"],
["tt", "1868"],
["tn", "216"],
["tr", "90"],
["tm", "993"],
["tc", "1649"],
["tv", "688"],
["vi", "1340"],
["ug", "256"],
["ua", "380"],
["ae", "971"],
["gb", "44"],
["us", "1"],
["uy", "598"],
["uz", "998"],
["vu", "678"],
["va", "39"],
["ve", "58"],
["vn", "84"],
["wf", "681"],
["eh", "212"],
["ye", "967"],
["zm", "260"],
["zw", "263"],
];
interface Country {
code: string;
name: string;
dialCode: string;
flag?: string;
}
function getCountryName(countryCode: string, locale = "en"): string {
try {
const regionNames = new Intl.DisplayNames([locale], { type: "region" });
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
}
function getFlagEmoji(countryCode: string): string {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountries(): Country[] {
return COUNTRY_DATA.map(([iso2, dialCode]): Country => {
const code = iso2.toUpperCase();
return {
code,
name: getCountryName(code),
dialCode: `+${dialCode}`,
flag: getFlagEmoji(code),
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
function detectCountryFromNumber(
value: string,
countries: Country[],
): Country | undefined {
if (!value || !value.startsWith("+")) return undefined;
const digits = value.slice(1).replace(/\D/g, "");
if (!digits) return undefined;
const sorted = [...countries].sort(
(a, b) => b.dialCode.length - a.dialCode.length,
);
const matches: Country[] = [];
for (const country of sorted) {
const dialCode = country.dialCode.slice(1);
if (digits.startsWith(dialCode)) {
matches.push(country);
}
}
if (matches.length === 0) return undefined;
if (matches.length > 1 && matches[0]?.dialCode === "+1") {
const usCountry = matches.find((c) => c.code === "US");
if (usCountry) return usCountry;
}
return matches[0];
}
function formatPhoneNumber(value: string, countries: Country[]): string {
if (!value) return "";
const normalized = value.startsWith("+") ? value : `+${value}`;
const digits = normalized.slice(1).replace(/\D/g, "");
if (!digits) return "+";
const detected = detectCountryFromNumber(`+${digits}`, countries);
const dialCodeLength = detected
? detected.dialCode.slice(1).length
: Math.min(digits.length, 3);
const countryCode = digits.slice(0, dialCodeLength);
const rest = digits.slice(dialCodeLength);
let formatted = `+${countryCode}`;
if (rest) {
formatted += " ";
for (let i = 0; i < rest.length; i++) {
if (i > 0 && i % 3 === 0) {
formatted += " ";
}
formatted += rest[i];
}
}
return formatted;
}
type RootElement = React.ComponentRef<typeof PhoneInput>;
interface StoreState {
value: string;
country: string;
open: boolean;
startsWithPlus: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface PhoneInputContextValue {
rootId: string;
countries: Country[];
placeholder: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const PhoneInputContext = React.createContext<PhoneInputContextValue | null>(
null,
);
function usePhoneInputContext(consumerName: string) {
const context = React.useContext(PhoneInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface PhoneInputProps extends React.ComponentProps<"div"> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
defaultCountry?: string;
country?: string;
onCountryChange?: (country: string) => void;
countries?: Country[];
name?: string;
placeholder?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag?: boolean;
}
function PhoneInput(props: PhoneInputProps) {
const {
value: valueProp,
defaultValue,
defaultCountry,
country: countryProp,
onValueChange,
onCountryChange,
countries = getCountries(),
name,
placeholder = "Enter phone number",
asChild,
disabled,
required,
readOnly,
invalid,
showFlag = true,
className,
id,
ref,
...rootProps
} = props;
const instanceId = React.useId();
const rootId = id ?? instanceId;
const inputRef = React.useRef<HTMLInputElement>(null);
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => {
const initialValue = valueProp ?? defaultValue ?? "";
const initialCountry = countryProp ?? defaultCountry ?? "";
return {
value: initialValue,
country: initialCountry,
open: false,
startsWithPlus: initialValue.startsWith("+"),
};
});
const propsRef = useAsRef({
onValueChange,
onCountryChange,
});
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "value" && typeof value === "string") {
stateRef.current.value = value;
propsRef.current.onValueChange?.(value);
} else if (key === "country" && typeof value === "string") {
stateRef.current.country = value;
propsRef.current.onCountryChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const value = useStore((state) => state.value, store);
const country = useStore((state) => state.country, store);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp]);
useIsomorphicLayoutEffect(() => {
if (countryProp !== undefined) {
store.setState("country", countryProp);
}
}, [countryProp]);
const startsWithPlus = useStore((state) => state.startsWithPlus, store);
React.useEffect(() => {
if (!value) return;
const digits = value.slice(1).replace(/\D/g, "");
const shouldDetect = startsWithPlus || digits.length >= 10;
if (!shouldDetect) return;
const detected = detectCountryFromNumber(value, countries);
if (detected && detected.code !== country) {
store.setState("country", detected.code);
}
}, [value, countries, country, store, startsWithPlus]);
const contextValue = React.useMemo<PhoneInputContextValue>(
() => ({
rootId,
countries,
placeholder,
disabled,
readOnly,
required,
invalid,
showFlag,
inputRef,
}),
[
rootId,
countries,
placeholder,
disabled,
required,
readOnly,
invalid,
showFlag,
],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<StoreContext.Provider value={store}>
<PhoneInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
data-slot="phone-input"
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
id={rootId}
{...rootProps}
ref={composedRef}
className={cn(
"relative flex h-10 w-full items-center rounded-md border border-input bg-background transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
/>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</PhoneInputContext.Provider>
</StoreContext.Provider>
);
}
interface PhoneInputCountrySelectProps
extends React.ComponentProps<typeof Popover>,
Pick<
React.ComponentProps<typeof PopoverTrigger>,
"disabled" | "className"
> {}
function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) {
const {
disabled: disabledProp,
className,
children,
onOpenChange: onOpenChangeProp,
...popoverProps
} = props;
const { countries, inputRef, disabled, showFlag } =
usePhoneInputContext(COUNTRY_SELECT_NAME);
const store = useStoreContext(COUNTRY_SELECT_NAME);
const country = useStore((state) => state.country);
const open = useStore((state) => state.open);
const isDisabled = disabledProp || disabled;
const countryContext = countries.find((c) => c.code === country);
const onOpenChange = React.useCallback(
(open: boolean) => {
store.setState("open", open);
onOpenChangeProp?.(open);
},
[store, onOpenChangeProp],
);
return (
<Popover open={open} onOpenChange={onOpenChange} {...popoverProps}>
<PopoverTrigger
data-slot="phone-input-country-select"
disabled={isDisabled}
className={cn(
"flex h-full shrink-0 items-center gap-2 rounded-l-md border-input border-r bg-transparent px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
>
{!countryContext ? (
<div className="h-4 w-6 rounded bg-muted/50" />
) : (
showFlag &&
countryContext.flag && (
<div className="w-6 text-lg leading-none">
{countryContext.flag}
</div>
)
)}
<ChevronDown className="size-4 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem
key={c.code}
value={`${c.name} ${c.dialCode} ${c.code}`}
onSelect={() => {
store.setState("country", c.code);
store.setState("open", false);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
{showFlag && c.flag && (
<span className="text-lg">{c.flag}</span>
)}
<span className="flex-1">{c.name}</span>
<span className="text-muted-foreground">{c.dialCode}</span>
<Check
className={cn(
"size-4",
country === c.code ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function PhoneInputField(props: React.ComponentProps<"input">) {
const {
onChange: onChangeProp,
className,
disabled: disabledProp,
readOnly: readOnlyProp,
required: requiredProp,
ref,
...inputProps
} = props;
const {
inputRef,
disabled,
invalid,
readOnly,
required,
placeholder,
countries,
} = usePhoneInputContext(FIELD_NAME);
const store = useStoreContext(FIELD_NAME);
const value = useStore((state) => state.value);
const composedRef = useComposedRefs(ref, inputRef);
const onChangeRef = useAsRef(onChangeProp);
const isDisabled = disabledProp || disabled;
const isReadOnly = readOnlyProp || readOnly;
const isRequired = requiredProp || required;
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled || isReadOnly) return;
onChangeRef.current?.(event);
if (event.defaultPrevented) return;
const inputValue = event.target.value;
const startsWithPlus = inputValue.startsWith("+");
const digits = inputValue.replace(/\D/g, "");
const newValue = digits ? `+${digits}` : startsWithPlus ? "+" : "";
store.setState("startsWithPlus", startsWithPlus);
store.setState("value", newValue);
},
[store, onChangeRef, isDisabled, isReadOnly],
);
const displayValue = React.useMemo(() => {
return formatPhoneNumber(value, countries);
}, [value, countries]);
return (
<Input
type="tel"
inputMode="tel"
aria-required={isRequired}
aria-invalid={invalid}
data-slot="phone-input-field"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...inputProps}
ref={composedRef}
className={cn(
"h-full flex-1 rounded-r-md rounded-l-none border-0 bg-transparent shadow-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:bg-transparent aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-transparent dark:aria-invalid:ring-destructive/40 dark:disabled:bg-transparent",
className,
)}
placeholder={placeholder}
value={displayValue}
onChange={onChange}
/>
);
}
export {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
//
useStore as usePhoneInput,
//
type PhoneInputProps,
};"use client";
import { Check, ChevronDown } from "lucide-react";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "PhoneInput";
const COUNTRY_SELECT_NAME = "PhoneInputCountrySelect";
const FIELD_NAME = "PhoneInputField";
/**
* @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js
* @format [iso2, dialCode]
*/
const COUNTRY_DATA: [string, string][] = [
["af", "93"],
["ax", "358"],
["al", "355"],
["dz", "213"],
["as", "1684"],
["ad", "376"],
["ao", "244"],
["ai", "1264"],
["ag", "1268"],
["ar", "54"],
["am", "374"],
["aw", "297"],
["au", "61"],
["at", "43"],
["az", "994"],
["bs", "1242"],
["bh", "973"],
["bd", "880"],
["bb", "1246"],
["by", "375"],
["be", "32"],
["bz", "501"],
["bj", "229"],
["bm", "1441"],
["bt", "975"],
["bo", "591"],
["ba", "387"],
["bw", "267"],
["br", "55"],
["io", "246"],
["vg", "1284"],
["bn", "673"],
["bg", "359"],
["bf", "226"],
["bi", "257"],
["kh", "855"],
["cm", "237"],
["ca", "1"],
["cv", "238"],
["bq", "599"],
["ky", "1345"],
["cf", "236"],
["td", "235"],
["cl", "56"],
["cn", "86"],
["co", "57"],
["km", "269"],
["cd", "243"],
["cg", "242"],
["ck", "682"],
["cr", "506"],
["ci", "225"],
["hr", "385"],
["cu", "53"],
["cw", "599"],
["cy", "357"],
["cz", "420"],
["dk", "45"],
["dj", "253"],
["dm", "1767"],
["do", "1"],
["ec", "593"],
["eg", "20"],
["sv", "503"],
["gq", "240"],
["er", "291"],
["ee", "372"],
["et", "251"],
["fk", "500"],
["fo", "298"],
["fj", "679"],
["fi", "358"],
["fr", "33"],
["gf", "594"],
["pf", "689"],
["ga", "241"],
["gm", "220"],
["ge", "995"],
["de", "49"],
["gh", "233"],
["gi", "350"],
["gr", "30"],
["gl", "299"],
["gd", "1473"],
["gp", "590"],
["gu", "1671"],
["gt", "502"],
["gg", "44"],
["gn", "224"],
["gw", "245"],
["gy", "592"],
["ht", "509"],
["hn", "504"],
["hk", "852"],
["hu", "36"],
["is", "354"],
["in", "91"],
["id", "62"],
["ir", "98"],
["iq", "964"],
["ie", "353"],
["im", "44"],
["il", "972"],
["it", "39"],
["jm", "1876"],
["jp", "81"],
["je", "44"],
["jo", "962"],
["kz", "7"],
["ke", "254"],
["ki", "686"],
["xk", "383"],
["kw", "965"],
["kg", "996"],
["la", "856"],
["lv", "371"],
["lb", "961"],
["ls", "266"],
["lr", "231"],
["ly", "218"],
["li", "423"],
["lt", "370"],
["lu", "352"],
["mo", "853"],
["mk", "389"],
["mg", "261"],
["mw", "265"],
["my", "60"],
["mv", "960"],
["ml", "223"],
["mt", "356"],
["mh", "692"],
["mq", "596"],
["mr", "222"],
["mu", "230"],
["mx", "52"],
["fm", "691"],
["md", "373"],
["mc", "377"],
["mn", "976"],
["me", "382"],
["ms", "1664"],
["ma", "212"],
["mz", "258"],
["mm", "95"],
["na", "264"],
["nr", "674"],
["np", "977"],
["nl", "31"],
["nc", "687"],
["nz", "64"],
["ni", "505"],
["ne", "227"],
["ng", "234"],
["nu", "683"],
["nf", "672"],
["kp", "850"],
["mp", "1670"],
["no", "47"],
["om", "968"],
["pk", "92"],
["pw", "680"],
["ps", "970"],
["pa", "507"],
["pg", "675"],
["py", "595"],
["pe", "51"],
["ph", "63"],
["pl", "48"],
["pt", "351"],
["pr", "1"],
["qa", "974"],
["re", "262"],
["ro", "40"],
["ru", "7"],
["rw", "250"],
["bl", "590"],
["sh", "290"],
["kn", "1869"],
["lc", "1758"],
["mf", "590"],
["pm", "508"],
["vc", "1784"],
["ws", "685"],
["sm", "378"],
["st", "239"],
["sa", "966"],
["sn", "221"],
["rs", "381"],
["sc", "248"],
["sl", "232"],
["sg", "65"],
["sx", "1721"],
["sk", "421"],
["si", "386"],
["sb", "677"],
["so", "252"],
["za", "27"],
["kr", "82"],
["ss", "211"],
["es", "34"],
["lk", "94"],
["sd", "249"],
["sr", "597"],
["sz", "268"],
["se", "46"],
["ch", "41"],
["sy", "963"],
["tw", "886"],
["tj", "992"],
["tz", "255"],
["th", "66"],
["tl", "670"],
["tg", "228"],
["tk", "690"],
["to", "676"],
["tt", "1868"],
["tn", "216"],
["tr", "90"],
["tm", "993"],
["tc", "1649"],
["tv", "688"],
["vi", "1340"],
["ug", "256"],
["ua", "380"],
["ae", "971"],
["gb", "44"],
["us", "1"],
["uy", "598"],
["uz", "998"],
["vu", "678"],
["va", "39"],
["ve", "58"],
["vn", "84"],
["wf", "681"],
["eh", "212"],
["ye", "967"],
["zm", "260"],
["zw", "263"],
];
interface Country {
code: string;
name: string;
dialCode: string;
flag?: string;
}
function getCountryName(countryCode: string, locale = "en"): string {
try {
const regionNames = new Intl.DisplayNames([locale], { type: "region" });
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
}
function getFlagEmoji(countryCode: string): string {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountries(): Country[] {
return COUNTRY_DATA.map(([iso2, dialCode]): Country => {
const code = iso2.toUpperCase();
return {
code,
name: getCountryName(code),
dialCode: `+${dialCode}`,
flag: getFlagEmoji(code),
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
function detectCountryFromNumber(
value: string,
countries: Country[],
): Country | undefined {
if (!value || !value.startsWith("+")) return undefined;
const digits = value.slice(1).replace(/\D/g, "");
if (!digits) return undefined;
const sorted = [...countries].sort(
(a, b) => b.dialCode.length - a.dialCode.length,
);
const matches: Country[] = [];
for (const country of sorted) {
const dialCode = country.dialCode.slice(1);
if (digits.startsWith(dialCode)) {
matches.push(country);
}
}
if (matches.length === 0) return undefined;
if (matches.length > 1 && matches[0]?.dialCode === "+1") {
const usCountry = matches.find((c) => c.code === "US");
if (usCountry) return usCountry;
}
return matches[0];
}
function formatPhoneNumber(value: string, countries: Country[]): string {
if (!value) return "";
const normalized = value.startsWith("+") ? value : `+${value}`;
const digits = normalized.slice(1).replace(/\D/g, "");
if (!digits) return "+";
const detected = detectCountryFromNumber(`+${digits}`, countries);
const dialCodeLength = detected
? detected.dialCode.slice(1).length
: Math.min(digits.length, 3);
const countryCode = digits.slice(0, dialCodeLength);
const rest = digits.slice(dialCodeLength);
let formatted = `+${countryCode}`;
if (rest) {
formatted += " ";
for (let i = 0; i < rest.length; i++) {
if (i > 0 && i % 3 === 0) {
formatted += " ";
}
formatted += rest[i];
}
}
return formatted;
}
type RootElement = React.ComponentRef<typeof PhoneInput>;
interface StoreState {
value: string;
country: string;
open: boolean;
startsWithPlus: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface PhoneInputContextValue {
rootId: string;
countries: Country[];
placeholder: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const PhoneInputContext = React.createContext<PhoneInputContextValue | null>(
null,
);
function usePhoneInputContext(consumerName: string) {
const context = React.useContext(PhoneInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface PhoneInputProps extends React.ComponentProps<"div"> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
defaultCountry?: string;
country?: string;
onCountryChange?: (country: string) => void;
countries?: Country[];
name?: string;
placeholder?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag?: boolean;
}
function PhoneInput(props: PhoneInputProps) {
const {
value: valueProp,
defaultValue,
defaultCountry,
country: countryProp,
onValueChange,
onCountryChange,
countries = getCountries(),
name,
placeholder = "Enter phone number",
asChild,
disabled,
required,
readOnly,
invalid,
showFlag = true,
className,
id,
ref,
...rootProps
} = props;
const instanceId = React.useId();
const rootId = id ?? instanceId;
const inputRef = React.useRef<HTMLInputElement>(null);
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => {
const initialValue = valueProp ?? defaultValue ?? "";
const initialCountry = countryProp ?? defaultCountry ?? "";
return {
value: initialValue,
country: initialCountry,
open: false,
startsWithPlus: initialValue.startsWith("+"),
};
});
const propsRef = useAsRef({
onValueChange,
onCountryChange,
});
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "value" && typeof value === "string") {
stateRef.current.value = value;
propsRef.current.onValueChange?.(value);
} else if (key === "country" && typeof value === "string") {
stateRef.current.country = value;
propsRef.current.onCountryChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const value = useStore((state) => state.value, store);
const country = useStore((state) => state.country, store);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp]);
useIsomorphicLayoutEffect(() => {
if (countryProp !== undefined) {
store.setState("country", countryProp);
}
}, [countryProp]);
const startsWithPlus = useStore((state) => state.startsWithPlus, store);
React.useEffect(() => {
if (!value) return;
const digits = value.slice(1).replace(/\D/g, "");
const shouldDetect = startsWithPlus || digits.length >= 10;
if (!shouldDetect) return;
const detected = detectCountryFromNumber(value, countries);
if (detected && detected.code !== country) {
store.setState("country", detected.code);
}
}, [value, countries, country, store, startsWithPlus]);
const contextValue = React.useMemo<PhoneInputContextValue>(
() => ({
rootId,
countries,
placeholder,
disabled,
readOnly,
required,
invalid,
showFlag,
inputRef,
}),
[
rootId,
countries,
placeholder,
disabled,
required,
readOnly,
invalid,
showFlag,
],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<StoreContext.Provider value={store}>
<PhoneInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
data-slot="phone-input"
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
id={rootId}
{...rootProps}
ref={composedRef}
className={cn(
"relative flex h-10 w-full items-center rounded-md border border-input bg-background transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
/>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</PhoneInputContext.Provider>
</StoreContext.Provider>
);
}
interface PhoneInputCountrySelectProps
extends React.ComponentProps<typeof Popover>,
Pick<
React.ComponentProps<typeof PopoverTrigger>,
"disabled" | "className"
> {}
function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) {
const {
disabled: disabledProp,
className,
children,
onOpenChange: onOpenChangeProp,
...popoverProps
} = props;
const { countries, inputRef, disabled, showFlag } =
usePhoneInputContext(COUNTRY_SELECT_NAME);
const store = useStoreContext(COUNTRY_SELECT_NAME);
const country = useStore((state) => state.country);
const open = useStore((state) => state.open);
const isDisabled = disabledProp || disabled;
const countryContext = countries.find((c) => c.code === country);
const onOpenChange = React.useCallback(
(open: boolean) => {
store.setState("open", open);
onOpenChangeProp?.(open);
},
[store, onOpenChangeProp],
);
return (
<Popover open={open} onOpenChange={onOpenChange} {...popoverProps}>
<PopoverTrigger
data-slot="phone-input-country-select"
disabled={isDisabled}
className={cn(
"flex h-full shrink-0 items-center gap-2 rounded-l-md border-input border-r bg-transparent px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
>
{!countryContext ? (
<div className="h-4 w-6 rounded bg-muted/50" />
) : (
showFlag &&
countryContext.flag && (
<div className="w-6 text-lg leading-none">
{countryContext.flag}
</div>
)
)}
<ChevronDown className="size-4 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem
key={c.code}
value={`${c.name} ${c.dialCode} ${c.code}`}
onSelect={() => {
store.setState("country", c.code);
store.setState("open", false);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
{showFlag && c.flag && (
<span className="text-lg">{c.flag}</span>
)}
<span className="flex-1">{c.name}</span>
<span className="text-muted-foreground">{c.dialCode}</span>
<Check
className={cn(
"size-4",
country === c.code ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function PhoneInputField(props: React.ComponentProps<"input">) {
const {
onChange: onChangeProp,
className,
disabled: disabledProp,
readOnly: readOnlyProp,
required: requiredProp,
ref,
...inputProps
} = props;
const {
inputRef,
disabled,
invalid,
readOnly,
required,
placeholder,
countries,
} = usePhoneInputContext(FIELD_NAME);
const store = useStoreContext(FIELD_NAME);
const value = useStore((state) => state.value);
const composedRef = useComposedRefs(ref, inputRef);
const onChangeRef = useAsRef(onChangeProp);
const isDisabled = disabledProp || disabled;
const isReadOnly = readOnlyProp || readOnly;
const isRequired = requiredProp || required;
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled || isReadOnly) return;
onChangeRef.current?.(event);
if (event.defaultPrevented) return;
const inputValue = event.target.value;
const startsWithPlus = inputValue.startsWith("+");
const digits = inputValue.replace(/\D/g, "");
const newValue = digits ? `+${digits}` : startsWithPlus ? "+" : "";
store.setState("startsWithPlus", startsWithPlus);
store.setState("value", newValue);
},
[store, onChangeRef, isDisabled, isReadOnly],
);
const displayValue = React.useMemo(() => {
return formatPhoneNumber(value, countries);
}, [value, countries]);
return (
<Input
type="tel"
inputMode="tel"
aria-required={isRequired}
aria-invalid={invalid}
data-slot="phone-input-field"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...inputProps}
ref={composedRef}
className={cn(
"h-full flex-1 rounded-r-md rounded-l-none border-0 bg-transparent shadow-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:bg-transparent aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-transparent dark:aria-invalid:ring-destructive/40 dark:disabled:bg-transparent",
className,
)}
placeholder={placeholder}
value={displayValue}
onChange={onChange}
/>
);
}
export {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
//
useStore as usePhoneInput,
//
type PhoneInputProps,
};"use client";
import { Check, ChevronDown } from "lucide-react";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "PhoneInput";
const COUNTRY_SELECT_NAME = "PhoneInputCountrySelect";
const FIELD_NAME = "PhoneInputField";
/**
* @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js
* @format [iso2, dialCode]
*/
const COUNTRY_DATA: [string, string][] = [
["af", "93"],
["ax", "358"],
["al", "355"],
["dz", "213"],
["as", "1684"],
["ad", "376"],
["ao", "244"],
["ai", "1264"],
["ag", "1268"],
["ar", "54"],
["am", "374"],
["aw", "297"],
["au", "61"],
["at", "43"],
["az", "994"],
["bs", "1242"],
["bh", "973"],
["bd", "880"],
["bb", "1246"],
["by", "375"],
["be", "32"],
["bz", "501"],
["bj", "229"],
["bm", "1441"],
["bt", "975"],
["bo", "591"],
["ba", "387"],
["bw", "267"],
["br", "55"],
["io", "246"],
["vg", "1284"],
["bn", "673"],
["bg", "359"],
["bf", "226"],
["bi", "257"],
["kh", "855"],
["cm", "237"],
["ca", "1"],
["cv", "238"],
["bq", "599"],
["ky", "1345"],
["cf", "236"],
["td", "235"],
["cl", "56"],
["cn", "86"],
["co", "57"],
["km", "269"],
["cd", "243"],
["cg", "242"],
["ck", "682"],
["cr", "506"],
["ci", "225"],
["hr", "385"],
["cu", "53"],
["cw", "599"],
["cy", "357"],
["cz", "420"],
["dk", "45"],
["dj", "253"],
["dm", "1767"],
["do", "1"],
["ec", "593"],
["eg", "20"],
["sv", "503"],
["gq", "240"],
["er", "291"],
["ee", "372"],
["et", "251"],
["fk", "500"],
["fo", "298"],
["fj", "679"],
["fi", "358"],
["fr", "33"],
["gf", "594"],
["pf", "689"],
["ga", "241"],
["gm", "220"],
["ge", "995"],
["de", "49"],
["gh", "233"],
["gi", "350"],
["gr", "30"],
["gl", "299"],
["gd", "1473"],
["gp", "590"],
["gu", "1671"],
["gt", "502"],
["gg", "44"],
["gn", "224"],
["gw", "245"],
["gy", "592"],
["ht", "509"],
["hn", "504"],
["hk", "852"],
["hu", "36"],
["is", "354"],
["in", "91"],
["id", "62"],
["ir", "98"],
["iq", "964"],
["ie", "353"],
["im", "44"],
["il", "972"],
["it", "39"],
["jm", "1876"],
["jp", "81"],
["je", "44"],
["jo", "962"],
["kz", "7"],
["ke", "254"],
["ki", "686"],
["xk", "383"],
["kw", "965"],
["kg", "996"],
["la", "856"],
["lv", "371"],
["lb", "961"],
["ls", "266"],
["lr", "231"],
["ly", "218"],
["li", "423"],
["lt", "370"],
["lu", "352"],
["mo", "853"],
["mk", "389"],
["mg", "261"],
["mw", "265"],
["my", "60"],
["mv", "960"],
["ml", "223"],
["mt", "356"],
["mh", "692"],
["mq", "596"],
["mr", "222"],
["mu", "230"],
["mx", "52"],
["fm", "691"],
["md", "373"],
["mc", "377"],
["mn", "976"],
["me", "382"],
["ms", "1664"],
["ma", "212"],
["mz", "258"],
["mm", "95"],
["na", "264"],
["nr", "674"],
["np", "977"],
["nl", "31"],
["nc", "687"],
["nz", "64"],
["ni", "505"],
["ne", "227"],
["ng", "234"],
["nu", "683"],
["nf", "672"],
["kp", "850"],
["mp", "1670"],
["no", "47"],
["om", "968"],
["pk", "92"],
["pw", "680"],
["ps", "970"],
["pa", "507"],
["pg", "675"],
["py", "595"],
["pe", "51"],
["ph", "63"],
["pl", "48"],
["pt", "351"],
["pr", "1"],
["qa", "974"],
["re", "262"],
["ro", "40"],
["ru", "7"],
["rw", "250"],
["bl", "590"],
["sh", "290"],
["kn", "1869"],
["lc", "1758"],
["mf", "590"],
["pm", "508"],
["vc", "1784"],
["ws", "685"],
["sm", "378"],
["st", "239"],
["sa", "966"],
["sn", "221"],
["rs", "381"],
["sc", "248"],
["sl", "232"],
["sg", "65"],
["sx", "1721"],
["sk", "421"],
["si", "386"],
["sb", "677"],
["so", "252"],
["za", "27"],
["kr", "82"],
["ss", "211"],
["es", "34"],
["lk", "94"],
["sd", "249"],
["sr", "597"],
["sz", "268"],
["se", "46"],
["ch", "41"],
["sy", "963"],
["tw", "886"],
["tj", "992"],
["tz", "255"],
["th", "66"],
["tl", "670"],
["tg", "228"],
["tk", "690"],
["to", "676"],
["tt", "1868"],
["tn", "216"],
["tr", "90"],
["tm", "993"],
["tc", "1649"],
["tv", "688"],
["vi", "1340"],
["ug", "256"],
["ua", "380"],
["ae", "971"],
["gb", "44"],
["us", "1"],
["uy", "598"],
["uz", "998"],
["vu", "678"],
["va", "39"],
["ve", "58"],
["vn", "84"],
["wf", "681"],
["eh", "212"],
["ye", "967"],
["zm", "260"],
["zw", "263"],
];
interface Country {
code: string;
name: string;
dialCode: string;
flag?: string;
}
function getCountryName(countryCode: string, locale = "en"): string {
try {
const regionNames = new Intl.DisplayNames([locale], { type: "region" });
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
}
function getFlagEmoji(countryCode: string): string {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountries(): Country[] {
return COUNTRY_DATA.map(([iso2, dialCode]): Country => {
const code = iso2.toUpperCase();
return {
code,
name: getCountryName(code),
dialCode: `+${dialCode}`,
flag: getFlagEmoji(code),
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
function detectCountryFromNumber(
value: string,
countries: Country[],
): Country | undefined {
if (!value || !value.startsWith("+")) return undefined;
const digits = value.slice(1).replace(/\D/g, "");
if (!digits) return undefined;
const sorted = [...countries].sort(
(a, b) => b.dialCode.length - a.dialCode.length,
);
const matches: Country[] = [];
for (const country of sorted) {
const dialCode = country.dialCode.slice(1);
if (digits.startsWith(dialCode)) {
matches.push(country);
}
}
if (matches.length === 0) return undefined;
if (matches.length > 1 && matches[0]?.dialCode === "+1") {
const usCountry = matches.find((c) => c.code === "US");
if (usCountry) return usCountry;
}
return matches[0];
}
function formatPhoneNumber(value: string, countries: Country[]): string {
if (!value) return "";
const normalized = value.startsWith("+") ? value : `+${value}`;
const digits = normalized.slice(1).replace(/\D/g, "");
if (!digits) return "+";
const detected = detectCountryFromNumber(`+${digits}`, countries);
const dialCodeLength = detected
? detected.dialCode.slice(1).length
: Math.min(digits.length, 3);
const countryCode = digits.slice(0, dialCodeLength);
const rest = digits.slice(dialCodeLength);
let formatted = `+${countryCode}`;
if (rest) {
formatted += " ";
for (let i = 0; i < rest.length; i++) {
if (i > 0 && i % 3 === 0) {
formatted += " ";
}
formatted += rest[i];
}
}
return formatted;
}
type RootElement = React.ComponentRef<typeof PhoneInput>;
interface StoreState {
value: string;
country: string;
open: boolean;
startsWithPlus: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface PhoneInputContextValue {
rootId: string;
countries: Country[];
placeholder: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const PhoneInputContext = React.createContext<PhoneInputContextValue | null>(
null,
);
function usePhoneInputContext(consumerName: string) {
const context = React.useContext(PhoneInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface PhoneInputProps extends React.ComponentProps<"div"> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
defaultCountry?: string;
country?: string;
onCountryChange?: (country: string) => void;
countries?: Country[];
name?: string;
placeholder?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag?: boolean;
}
function PhoneInput(props: PhoneInputProps) {
const {
value: valueProp,
defaultValue,
defaultCountry,
country: countryProp,
onValueChange,
onCountryChange,
countries = getCountries(),
name,
placeholder = "Enter phone number",
asChild,
disabled,
required,
readOnly,
invalid,
showFlag = true,
className,
id,
ref,
...rootProps
} = props;
const instanceId = React.useId();
const rootId = id ?? instanceId;
const inputRef = React.useRef<HTMLInputElement>(null);
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => {
const initialValue = valueProp ?? defaultValue ?? "";
const initialCountry = countryProp ?? defaultCountry ?? "";
return {
value: initialValue,
country: initialCountry,
open: false,
startsWithPlus: initialValue.startsWith("+"),
};
});
const propsRef = useAsRef({
onValueChange,
onCountryChange,
});
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "value" && typeof value === "string") {
stateRef.current.value = value;
propsRef.current.onValueChange?.(value);
} else if (key === "country" && typeof value === "string") {
stateRef.current.country = value;
propsRef.current.onCountryChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const value = useStore((state) => state.value, store);
const country = useStore((state) => state.country, store);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp]);
useIsomorphicLayoutEffect(() => {
if (countryProp !== undefined) {
store.setState("country", countryProp);
}
}, [countryProp]);
const startsWithPlus = useStore((state) => state.startsWithPlus, store);
React.useEffect(() => {
if (!value) return;
const digits = value.slice(1).replace(/\D/g, "");
const shouldDetect = startsWithPlus || digits.length >= 10;
if (!shouldDetect) return;
const detected = detectCountryFromNumber(value, countries);
if (detected && detected.code !== country) {
store.setState("country", detected.code);
}
}, [value, countries, country, store, startsWithPlus]);
const contextValue = React.useMemo<PhoneInputContextValue>(
() => ({
rootId,
countries,
placeholder,
disabled,
readOnly,
required,
invalid,
showFlag,
inputRef,
}),
[
rootId,
countries,
placeholder,
disabled,
required,
readOnly,
invalid,
showFlag,
],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<StoreContext.Provider value={store}>
<PhoneInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
data-slot="phone-input"
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
id={rootId}
{...rootProps}
ref={composedRef}
className={cn(
"relative flex h-10 w-full items-center rounded-md border border-input bg-background transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
/>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</PhoneInputContext.Provider>
</StoreContext.Provider>
);
}
interface PhoneInputCountrySelectProps
extends React.ComponentProps<typeof Popover>,
Pick<
React.ComponentProps<typeof PopoverTrigger>,
"disabled" | "className"
> {}
function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) {
const {
disabled: disabledProp,
className,
children,
onOpenChange: onOpenChangeProp,
...popoverProps
} = props;
const { countries, inputRef, disabled, showFlag } =
usePhoneInputContext(COUNTRY_SELECT_NAME);
const store = useStoreContext(COUNTRY_SELECT_NAME);
const country = useStore((state) => state.country);
const open = useStore((state) => state.open);
const isDisabled = disabledProp || disabled;
const countryContext = countries.find((c) => c.code === country);
const onOpenChange = React.useCallback(
(open: boolean) => {
store.setState("open", open);
onOpenChangeProp?.(open);
},
[store, onOpenChangeProp],
);
return (
<Popover open={open} onOpenChange={onOpenChange} {...popoverProps}>
<PopoverTrigger
data-slot="phone-input-country-select"
disabled={isDisabled}
className={cn(
"flex h-full shrink-0 items-center gap-2 rounded-l-md border-input border-r bg-transparent px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
>
{!countryContext ? (
<div className="h-4 w-6 rounded bg-muted/50" />
) : (
showFlag &&
countryContext.flag && (
<div className="w-6 text-lg leading-none">
{countryContext.flag}
</div>
)
)}
<ChevronDown className="size-4 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem
key={c.code}
value={`${c.name} ${c.dialCode} ${c.code}`}
onSelect={() => {
store.setState("country", c.code);
store.setState("open", false);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
{showFlag && c.flag && (
<span className="text-lg">{c.flag}</span>
)}
<span className="flex-1">{c.name}</span>
<span className="text-muted-foreground">{c.dialCode}</span>
<Check
className={cn(
"size-4",
country === c.code ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function PhoneInputField(props: React.ComponentProps<"input">) {
const {
onChange: onChangeProp,
className,
disabled: disabledProp,
readOnly: readOnlyProp,
required: requiredProp,
ref,
...inputProps
} = props;
const {
inputRef,
disabled,
invalid,
readOnly,
required,
placeholder,
countries,
} = usePhoneInputContext(FIELD_NAME);
const store = useStoreContext(FIELD_NAME);
const value = useStore((state) => state.value);
const composedRef = useComposedRefs(ref, inputRef);
const onChangeRef = useAsRef(onChangeProp);
const isDisabled = disabledProp || disabled;
const isReadOnly = readOnlyProp || readOnly;
const isRequired = requiredProp || required;
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled || isReadOnly) return;
onChangeRef.current?.(event);
if (event.defaultPrevented) return;
const inputValue = event.target.value;
const startsWithPlus = inputValue.startsWith("+");
const digits = inputValue.replace(/\D/g, "");
const newValue = digits ? `+${digits}` : startsWithPlus ? "+" : "";
store.setState("startsWithPlus", startsWithPlus);
store.setState("value", newValue);
},
[store, onChangeRef, isDisabled, isReadOnly],
);
const displayValue = React.useMemo(() => {
return formatPhoneNumber(value, countries);
}, [value, countries]);
return (
<Input
type="tel"
inputMode="tel"
aria-required={isRequired}
aria-invalid={invalid}
data-slot="phone-input-field"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...inputProps}
ref={composedRef}
className={cn(
"h-full flex-1 rounded-r-md rounded-l-none border-0 bg-transparent shadow-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:bg-transparent aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-transparent dark:aria-invalid:ring-destructive/40 dark:disabled:bg-transparent",
className,
)}
placeholder={placeholder}
value={displayValue}
onChange={onChange}
/>
);
}
export {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
//
useStore as usePhoneInput,
//
type PhoneInputProps,
};Update the import paths to match your project setup.
Layout
Import the parts, and compose them together.
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
return (
<PhoneInput>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
)Examples
Custom Countries
Provide a custom list of countries to display in the dropdown.
import type { PhoneInputProps } from "@/components/ui/phone-input";
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
const NORTH_AMERICAN_COUNTRIES: PhoneInputProps["countries"] = [
{ code: "US", name: "United States", dialCode: "+1", flag: "🇺🇸" },
{ code: "CA", name: "Canada", dialCode: "+1", flag: "🇨🇦" },
{ code: "MX", name: "Mexico", dialCode: "+52", flag: "🇲🇽" },
{ code: "BR", name: "Brazil", dialCode: "+55", flag: "🇧🇷" },
];
export function PhoneInputCustomCountriesDemo() {
return (
<PhoneInput
defaultValue="+14085551234"
defaultCountry="US"
countries={NORTH_AMERICAN_COUNTRIES}
>
<PhoneInputCountrySelect />
<PhoneInputField placeholder="Type phone number" />
</PhoneInput>
);
}With Form
Use the phone input component in a form with validation.
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
const FormSchema = z.object({
phone: z.string().min(1, {
message: "Phone number is required.",
}),
country: z.string().min(1, {
message: "Country is required.",
}),
});
export function PhoneInputFormDemo() {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
phone: "",
country: "US",
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
const country = data.country;
const phoneNumber = data.phone;
toast.success("Phone number submitted", {
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify({ country, phone: phoneNumber }, null, 2)}
</code>
</pre>
),
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-6">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone Number</FormLabel>
<FormControl>
<PhoneInput
value={field.value}
onValueChange={field.onChange}
country={form.watch("country")}
onCountryChange={(country) =>
form.setValue("country", country)
}
required
>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
</FormControl>
<FormDescription>
Enter your phone number with country code.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}API Reference
PhoneInput
The root container component that acts as both the wrapper and input group. Handles layout, borders, and focus states.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-disabled] | Present when the phone input is disabled |
[data-invalid] | Present when the phone input is invalid |
[data-readonly] | Present when the phone input is read-only |
PhoneInputCountrySelect
The button component that triggers the country dropdown. Uses Popover and Command internally for the country list.
Prop
Type
PhoneInputField
The input field component for entering the phone number.
Prop
Type
Accessibility
Keyboard Interactions
| Key | Description |
|---|---|
| Tab | Moves focus to the next focusable element (country select or phone input field). |
| SpaceEnter | Opens the country dropdown when focused on the country select button. |
| Escape | Closes the country dropdown. |
| ArrowUpArrowDown | Navigate through country items when the dropdown is open. |
| HomeEnd | Jump to first or last country in the list. |
| Type to search | Filter countries by name, dial code, or country code as you type. |