175 lines
4.7 KiB
Svelte
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>
|