Files
sui/components/PhoneInput.svelte
2025-04-13 07:56:23 -07:00

175 lines
4.7 KiB
Svelte

<script lang="ts">
import Label from './Label.svelte';
import { Country, type ICountry } from 'country-state-city';
import ExpandableCombobox, { type ComboboxItem } from './Combobox.svelte';
import StyledRawInput from './StyledRawInput.svelte';
import { AsYouType, type PhoneNumber, type CountryCode } from 'libphonenumber-js';
let {
name,
value = $bindable<PhoneNumber | undefined>(undefined),
number = $bindable(''), // consider making this private, it should only be used internally
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;
number?: string;
country?: ICountry;
defaultISO?: string;
required?: boolean;
label?: string;
placeholder?: string;
invalidMessage?: string;
} = $props();
let selectedCountryItem = $state<ComboboxItem | 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: ComboboxItem[] = 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);
}
};
// 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;
});
</script>
{#snippet renderIcon(item: ComboboxItem)}
{#if countrycodeMap[item.value]?.flag}
{countrycodeMap[item.value].flag}
{/if}
{/snippet}
<div>
{#if label}
<Label for={name}>{label}</Label>
{/if}
<!-- Hidden input stores international E.164 formatted number -->
<input type="hidden" {name} 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"
items={options}
bind:value={selectedCountryItem}
bind:open={countriesOpen}
{required}
onchange={(e) => {
country = countrycodeMap[e.value.value];
}}
onvalidate={(e) => {
countriesValid = e.detail.valid;
}}
/>
</div>
<div class="w-full">
<StyledRawInput
type="tel"
{placeholder}
name="{name}_number"
bind:value={number}
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();
}}
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 (formatted.length >= input.value.length) input.value = formatted;
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();
}
}
}}
/>
</div>
</div>
<div class={['opacity-0 transition-opacity', (!countriesValid || !numberValid) && 'opacity-100']}>
<Label for={name} error>
{invalidMessage !== undefined && invalidMessage != '' ? invalidMessage : 'Field is required'}
</Label>
</div>
</div>