Merge branch 'phone-number-input-fix'

This commit is contained in:
RevIQ
2026-01-10 16:30:30 +08:00
5 changed files with 126 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";
import { Input } from "$lib/components/ui/input";
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 { Separator } from "$lib/components/ui/separator";
import { cn } from "$lib/utils";
@@ -224,9 +225,8 @@ function getInitials(name: string | null | undefined): string {
<div class="space-y-2">
<Label for="phoneNumber">Phone number</Label>
<Input
<PhoneNumberInput
id="phoneNumber"
type="tel"
placeholder="+1 555 123 4567"
bind:value={phoneNumber}
onblur={handlePhoneBlur}