Add PhoneNumberInput component with libphonenumber-js formatting

Uses AsYouType for real-time phone number formatting as user types.
Implements digit-based cursor positioning to handle formatting changes
without cursor jumping.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-10 16:07:32 +08:00
parent ddd7c0c03b
commit f394b80028
3 changed files with 120 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
import Root from "./phone-number-input.svelte";
export {
Root,
//
Root as PhoneNumberInput,
};

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import { AsYouType, type CountryCode } from "libphonenumber-js";
import { cn, type WithElementRef } from "$lib/utils.js";
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type" | "value"> & {
value?: string;
defaultCountry?: CountryCode;
}
>;
let {
ref = $bindable(null),
value = $bindable(""),
defaultCountry = "US",
class: className,
"data-slot": dataSlot = "phone-input",
oninput,
...restProps
}: Props = $props();
// Format initial value on mount
let formattedValue = $state("");
$effect(() => {
// Only format if value changed externally (not from our own input)
if (value && value !== formattedValue) {
const formatter = new AsYouType(defaultCountry);
formattedValue = formatter.input(value);
value = formattedValue;
} else if (!value) {
formattedValue = "";
}
});
/**
* Count the number of digit characters before a given position in a string.
*/
function countDigitsBefore(str: string, position: number): number {
let count = 0;
for (let i = 0; i < position && i < str.length; i++) {
if (/\d/.test(str[i])) {
count++;
}
}
return count;
}
/**
* Find the position in a string where a given number of digits have been seen.
*/
function findPositionAfterDigits(str: string, digitCount: number): number {
let count = 0;
for (let i = 0; i < str.length; i++) {
if (/\d/.test(str[i])) {
count++;
if (count === digitCount) {
return i + 1;
}
}
}
return str.length;
}
function handleInput(e: Event) {
const input = e.target as HTMLInputElement;
const cursorPosition = input.selectionStart ?? 0;
const rawValue = input.value;
// Count digits before cursor in the raw input
const digitsBefore = countDigitsBefore(rawValue, cursorPosition);
// Format the input
const formatter = new AsYouType(defaultCountry);
const formatted = formatter.input(rawValue);
// Update state
formattedValue = formatted;
value = formatted;
// Calculate new cursor position: find where the same number of digits ends up
const newPosition = findPositionAfterDigits(formatted, digitsBefore);
// Set cursor position synchronously after updating the input
// We need to wait for Svelte to update the DOM
queueMicrotask(() => {
input.setSelectionRange(newPosition, newPosition);
});
// Call the original oninput if provided
if (oninput) {
oninput(e as Event & { currentTarget: HTMLInputElement });
}
}
</script>
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="tel"
value={formattedValue}
oninput={handleInput}
{...restProps}
/>

View File

@@ -16,6 +16,7 @@ import {
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { PhoneNumberInput } from "$lib/components/ui/phone-number-input";
import { LoadingButton } from "$lib/components/ui/loading-button"; import { LoadingButton } from "$lib/components/ui/loading-button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
@@ -224,9 +225,8 @@ function getInitials(name: string | null | undefined): string {
<div class="space-y-2"> <div class="space-y-2">
<Label for="phoneNumber">Phone number</Label> <Label for="phoneNumber">Phone number</Label>
<Input <PhoneNumberInput
id="phoneNumber" id="phoneNumber"
type="tel"
placeholder="+1 555 123 4567" placeholder="+1 555 123 4567"
bind:value={phoneNumber} bind:value={phoneNumber}
onblur={handlePhoneBlur} onblur={handlePhoneBlur}