223 lines
5.8 KiB
Svelte
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>
|