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