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:
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./phone-number-input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as PhoneNumberInput,
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user