phone input: make name optional, custom class support, fix formatting

This commit is contained in:
Elijah Duffy
2025-07-03 11:51:46 -07:00
parent ff9af6dd0a
commit ef7bc6bbab

View File

@@ -1,30 +1,37 @@
<script lang="ts">
import Label from './Label.svelte';
import { Country, type ICountry } from 'country-state-city';
import ExpandableCombobox, { type ComboboxItem } from './Combobox.svelte';
import Combobox, { type ComboboxItem } from './Combobox.svelte';
import StyledRawInput from './StyledRawInput.svelte';
import { AsYouType, type PhoneNumber, type CountryCode } from 'libphonenumber-js';
import { generateIdentifier } from './util.js';
import type { ClassValue } from 'svelte/elements';
let {
name,
value = $bindable<PhoneNumber | undefined>(undefined),
country = $bindable<ICountry | undefined>(), // consider making this private, it should only be used internally
defaultISO,
required = false,
label,
placeholder = 'Phone number',
invalidMessage
}: {
name: string;
interface Props {
name?: string;
value?: PhoneNumber;
country?: ICountry;
defaultISO?: string;
required?: boolean;
label?: string;
placeholder?: string;
invalidMessage?: string;
} = $props();
class?: ClassValue | null | undefined;
}
let {
name,
value = $bindable<PhoneNumber | undefined>(undefined),
defaultISO,
required = false,
label,
placeholder = 'Phone number',
invalidMessage = 'Valid phone number is required',
class: classValue
}: Props = $props();
const id = $derived(generateIdentifier('phone-input', name));
let lastRawValue = $state('');
let country = $state<ICountry | undefined>();
let selectedCountryItem = $state<ComboboxItem | undefined>();
let countriesOpen = $state<boolean>(false);
let countriesValid = $state<boolean>(true);
@@ -69,9 +76,7 @@
if (locationData.status !== 'fail') {
const { countryCode } = locationData;
country = countryCode;
console.log('updating country code according to IP', country);
}
setCountryByISO(country);
};
@@ -81,11 +86,13 @@
}
// if country is still blank (probably invalid defaultISO), set it to the first country
if (!country) {
if (!(() => country)()) {
country = countries[0];
if (defaultISO)
console.warn(`PhoneInput: Invalid defaultISO "${defaultISO}", defaulting to ${country.name}`);
console.warn(
`PhoneInput: Invalid defaultISO "${defaultISO}", defaulting to ${(() => country)().name}`
);
}
selectedCountryItem = options.find((item) => item.value === country?.isoCode);
@@ -102,21 +109,20 @@
{/if}
{/snippet}
<div>
<div class={classValue}>
{#if label}
<Label for={name}>{label}</Label>
<Label for={id}>{label}</Label>
{/if}
<!-- Hidden input stores international E.164 formatted number -->
<input type="hidden" {name} value={value?.number ?? ''} />
<input type="hidden" {name} {id} value={value?.number ?? ''} />
<div class="flex gap-2">
<div
class={['transition-width', countriesOpen && 'w-full!']}
style="width: {phonecode.length * 1.5 + 12.5}ch;"
>
<ExpandableCombobox
name="{name}_country"
<Combobox
{options}
bind:value={selectedCountryItem}
bind:open={countriesOpen}
@@ -134,7 +140,6 @@
<StyledRawInput
type="tel"
{placeholder}
name="{name}_number"
validate={{
required: required,
func: () => !required || (value !== undefined && value.isValid())
@@ -153,6 +158,12 @@
e.preventDefault();
}}
onbeforeinput={(event: Event) => {
const target = event.target as HTMLInputElement;
if (target) {
lastRawValue = target.value;
}
}}
oninput={(event: Event) => {
const e = event as InputEvent;
if (!e.target) return;
@@ -161,15 +172,20 @@
const formatter = new AsYouType(formatterCountryCode);
const formatted = formatter.input(input.value);
if (formatted.length >= input.value.length) input.value = formatted;
if (input.value.length >= lastRawValue.length) {
setTimeout(() => {
input.value = formatted;
}, 1);
}
value = formatter.getNumber();
console.log('updated value', value, value?.isValid());
if (formatter.isValid() && formatter.isInternational() && value) {
const country = formatter.getCountry();
if (country) {
setCountryByISO(country);
input.value = value.formatNational();
setTimeout(() => {
if (value) input.value = value.formatNational();
}, 1);
}
}
}}
@@ -178,8 +194,8 @@
</div>
<div class={['opacity-0 transition-opacity', (!countriesValid || !numberValid) && 'opacity-100']}>
<Label for={name} error>
{invalidMessage !== undefined && invalidMessage != '' ? invalidMessage : 'Field is required'}
<Label for={id} error>
{invalidMessage}
</Label>
</div>
</div>