phone input: make name optional, custom class support, fix formatting
This commit is contained in:
@@ -1,30 +1,37 @@
|
||||
<script lang="ts">
|
||||
import Label from './Label.svelte';
|
||||
import { Country, type ICountry } from 'country-state-city';
|
||||
import ExpandableCombobox, { type ComboboxItem } from './Combobox.svelte';
|
||||
import Combobox, { type ComboboxItem } from './Combobox.svelte';
|
||||
import StyledRawInput from './StyledRawInput.svelte';
|
||||
import { AsYouType, type PhoneNumber, type CountryCode } from 'libphonenumber-js';
|
||||
import { generateIdentifier } from './util.js';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
name,
|
||||
value = $bindable<PhoneNumber | undefined>(undefined),
|
||||
country = $bindable<ICountry | undefined>(), // consider making this private, it should only be used internally
|
||||
defaultISO,
|
||||
required = false,
|
||||
label,
|
||||
placeholder = 'Phone number',
|
||||
invalidMessage
|
||||
}: {
|
||||
name: string;
|
||||
interface Props {
|
||||
name?: string;
|
||||
value?: PhoneNumber;
|
||||
country?: ICountry;
|
||||
defaultISO?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
invalidMessage?: string;
|
||||
} = $props();
|
||||
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 country = $state<ICountry | undefined>();
|
||||
let selectedCountryItem = $state<ComboboxItem | undefined>();
|
||||
let countriesOpen = $state<boolean>(false);
|
||||
let countriesValid = $state<boolean>(true);
|
||||
@@ -69,9 +76,7 @@
|
||||
if (locationData.status !== 'fail') {
|
||||
const { countryCode } = locationData;
|
||||
country = countryCode;
|
||||
console.log('updating country code according to IP', country);
|
||||
}
|
||||
|
||||
setCountryByISO(country);
|
||||
};
|
||||
|
||||
@@ -81,11 +86,13 @@
|
||||
}
|
||||
|
||||
// if country is still blank (probably invalid defaultISO), set it to the first country
|
||||
if (!country) {
|
||||
if (!(() => country)()) {
|
||||
country = countries[0];
|
||||
|
||||
if (defaultISO)
|
||||
console.warn(`PhoneInput: Invalid defaultISO "${defaultISO}", defaulting to ${country.name}`);
|
||||
console.warn(
|
||||
`PhoneInput: Invalid defaultISO "${defaultISO}", defaulting to ${(() => country)().name}`
|
||||
);
|
||||
}
|
||||
|
||||
selectedCountryItem = options.find((item) => item.value === country?.isoCode);
|
||||
@@ -102,21 +109,20 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div>
|
||||
<div class={classValue}>
|
||||
{#if label}
|
||||
<Label for={name}>{label}</Label>
|
||||
<Label for={id}>{label}</Label>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden input stores international E.164 formatted number -->
|
||||
<input type="hidden" {name} value={value?.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;"
|
||||
>
|
||||
<ExpandableCombobox
|
||||
name="{name}_country"
|
||||
<Combobox
|
||||
{options}
|
||||
bind:value={selectedCountryItem}
|
||||
bind:open={countriesOpen}
|
||||
@@ -134,7 +140,6 @@
|
||||
<StyledRawInput
|
||||
type="tel"
|
||||
{placeholder}
|
||||
name="{name}_number"
|
||||
validate={{
|
||||
required: required,
|
||||
func: () => !required || (value !== undefined && value.isValid())
|
||||
@@ -153,6 +158,12 @@
|
||||
|
||||
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;
|
||||
@@ -161,15 +172,20 @@
|
||||
const formatter = new AsYouType(formatterCountryCode);
|
||||
const formatted = formatter.input(input.value);
|
||||
|
||||
if (formatted.length >= input.value.length) input.value = formatted;
|
||||
if (input.value.length >= lastRawValue.length) {
|
||||
setTimeout(() => {
|
||||
input.value = formatted;
|
||||
}, 1);
|
||||
}
|
||||
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();
|
||||
setTimeout(() => {
|
||||
if (value) input.value = value.formatNational();
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -178,8 +194,8 @@
|
||||
</div>
|
||||
|
||||
<div class={['opacity-0 transition-opacity', (!countriesValid || !numberValid) && 'opacity-100']}>
|
||||
<Label for={name} error>
|
||||
{invalidMessage !== undefined && invalidMessage != '' ? invalidMessage : 'Field is required'}
|
||||
<Label for={id} error>
|
||||
{invalidMessage}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user