Files
sui/src/lib/PhoneInput.svelte
2025-07-04 03:17:16 -07:00

223 lines
5.8 KiB
Svelte

<script lang="ts">
import Label from './Label.svelte';
import { Country, type ICountry } from 'country-state-city';
import Combobox, { type ComboboxOption } from './Combobox.svelte';
import StyledRawInput from './StyledRawInput.svelte';
import { AsYouType, type PhoneNumber, type CountryCode } from 'libphonenumber-js';
import { generateIdentifier } from './util';
import type { ClassValue } from 'svelte/elements';
import { untrack } from 'svelte';
interface Props {
name?: string;
value?: PhoneNumber;
defaultISO?: string;
required?: boolean;
label?: string;
placeholder?: string;
invalidMessage?: string;
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 lastValue = $state<PhoneNumber | undefined>(value);
let country = $state<ICountry | undefined>();
let selectedCountryItem = $state<ComboboxOption | undefined>();
let countriesOpen = $state<boolean>(false);
let countriesValid = $state<boolean>(true);
let numberValid = $state<boolean>(true);
const countries = Country.getAllCountries();
const countrycodeMap = countries.reduce(
(acc, country) => {
acc[country.isoCode] = country;
return acc;
},
{} as Record<string, ICountry>
);
const options: ComboboxOption[] = countries.map((country) => ({
value: country.isoCode,
label: `${country.name} (+${country.phonecode})`,
preview: `+${country.phonecode}`,
icon: renderIcon
}));
let phonecode: string = $derived.by(() => {
if (country) {
return countrycodeMap[country.isoCode]?.phonecode ?? '';
}
return '';
});
// setCountryByISO sets the country to match a given ISO code
export const setCountryByISO = (iso: string) => {
if (iso in countrycodeMap) {
countriesOpen = false;
country = countrycodeMap[iso];
selectedCountryItem = options.find((item) => item.value === country?.isoCode);
}
};
// setCountryByIP queries ip-api.com for user location based on IP
export const setCountryByIP = async (ip: string) => {
const res = await fetch(`http://ip-api.com/json/${ip}`);
const locationData = await res.json();
let country = 'CA'; // default to Canada
if (locationData.status !== 'fail') {
const { countryCode } = locationData;
country = countryCode;
}
setCountryByISO(country);
};
// set the default country based on the provided ISO code
if (defaultISO) {
setCountryByISO(defaultISO);
}
// if country is still blank (probably invalid defaultISO), set it to the first country
if (!(() => country)()) {
country = countries[0];
if (defaultISO)
console.warn(
`PhoneInput: Invalid defaultISO "${defaultISO}", defaulting to ${(() => country)().name}`
);
}
selectedCountryItem = options.find((item) => item.value === country?.isoCode);
let formatterCountryCode: CountryCode | undefined = $derived.by(() => {
if (country) return country.isoCode as CountryCode;
return undefined;
});
let renderValue = value?.formatNational() ?? '';
let renderInputElement = $state<HTMLInputElement | null>(null);
$effect(() => {
if (value) {
untrack(() => {
if (renderInputElement) {
renderInputElement.value = value?.formatNational() ?? '';
}
});
}
});
</script>
{#snippet renderIcon(item: ComboboxOption)}
{#if countrycodeMap[item.value]?.flag}
{countrycodeMap[item.value].flag}
{/if}
{/snippet}
<div class={classValue}>
{#if label}
<Label for={id}>{label}</Label>
{/if}
<!-- Hidden input stores international E.164 formatted 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;"
>
<Combobox
{options}
bind:value={selectedCountryItem}
bind:open={countriesOpen}
{required}
onchange={(item) => {
country = countrycodeMap[item.value];
}}
onvalidate={(e) => {
countriesValid = e.detail.valid;
}}
invalidMessage={null}
/>
</div>
<div class="w-full">
<StyledRawInput
value={renderValue}
bind:ref={renderInputElement}
type="tel"
{placeholder}
validate={{
required: required,
func: () => !required || (value !== undefined && value.isValid())
}}
onvalidate={(e) => {
numberValid = e.detail.valid;
}}
onkeydown={(e) => {
if (e.ctrlKey || e.key.length > 1) {
return;
}
if (/[0-9-()+]/.test(e.key)) {
return;
}
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;
const input = e.target as HTMLInputElement;
const formatter = new AsYouType(formatterCountryCode);
const formatted = formatter.input(input.value);
if (input.value.length >= lastRawValue.length) {
setTimeout(() => {
input.value = formatted;
}, 1);
}
lastValue = formatter.getNumber();
if (formatter.isValid() && formatter.isInternational() && value) {
const country = formatter.getCountry();
if (country) {
setCountryByISO(country);
setTimeout(() => {
if (lastValue) input.value = lastValue.formatNational();
}, 1);
}
}
}}
onblur={() => {
value = lastValue;
}}
/>
</div>
</div>
<div class={['opacity-0 transition-opacity', (!countriesValid || !numberValid) && 'opacity-100']}>
<Label for={id} error>
{invalidMessage}
</Label>
</div>
</div>