fix ui package, use sv to create
recreated package using sv, marking correctly as a library. seems I was missing some sveltekit tooling somewhere along the way otherwise.
This commit is contained in:
174
src/lib/PhoneInput.svelte
Normal file
174
src/lib/PhoneInput.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user