monacousa-portal/components/PhoneInputWrapper.vue

831 lines
21 KiB
Vue

<template>
<div class="phone-input-wrapper" :class="{ 'phone-input-wrapper--mobile': isMobile }">
<v-text-field
v-model="localNumber"
:label="label"
:placeholder="placeholder"
:error="error"
:error-messages="errorMessage"
:hint="helpText"
:persistent-hint="!!helpText"
:required="required"
:disabled="disabled"
variant="outlined"
:density="isMobile ? 'default' : 'comfortable'"
class="phone-text-field"
@input="handleInput"
@blur="handleBlur"
>
<template #prepend-inner>
<!-- Country Selector -->
<v-menu
v-model="dropdownOpen"
:close-on-content-click="false"
location="bottom start"
:offset="isMobile ? 8 : 4"
:max-height="isMobile ? '70vh' : '300px'"
min-width="280"
:transition="isMobile ? 'slide-y-transition' : 'fade-transition'"
>
<template #activator="{ props: menuProps }">
<div
v-bind="menuProps"
class="country-selector"
:class="{
'country-selector--open': dropdownOpen,
'country-selector--mobile': isMobile
}"
>
<img
:src="flagUrl"
:alt="`${selectedCountry.name} flag`"
class="country-flag"
@error="handleFlagError"
/>
<span class="country-code">{{ selectedCountry.dialCode }}</span>
<v-icon
:size="isMobile ? 18 : 16"
class="dropdown-icon"
:class="{ 'dropdown-icon--rotated': dropdownOpen }"
>
mdi-chevron-down
</v-icon>
</div>
</template>
<!-- Mobile Full-Screen Overlay -->
<div
v-if="isMobile && dropdownOpen"
class="mobile-overlay"
@click="closeDropdown"
@touchstart="handleOverlayTouch"
/>
<!-- Dropdown Content -->
<v-card
class="country-dropdown"
:class="{ 'country-dropdown--mobile': isMobile }"
elevation="8"
>
<!-- Mobile Header -->
<div v-if="isMobile" class="mobile-header">
<h3 class="mobile-title">Select Country</h3>
<v-btn
icon="mdi-close"
variant="text"
size="small"
@click="closeDropdown"
class="close-btn"
/>
</div>
<!-- Search Bar -->
<div class="search-container">
<v-text-field
v-model="searchQuery"
placeholder="Search countries..."
variant="outlined"
:density="isMobile ? 'default' : 'compact'"
prepend-inner-icon="mdi-magnify"
hide-details
class="search-input"
:autofocus="!isMobile"
clearable
/>
</div>
<!-- Country List -->
<v-list
class="country-list"
:class="{ 'country-list--mobile': isMobile }"
:density="isMobile ? 'default' : 'compact'"
>
<v-list-item
v-for="country in filteredCountries"
:key="country.iso2"
:class="{
'country-item': true,
'country-item--selected': country.iso2 === selectedCountry.iso2,
'country-item--preferred': isPreferredCountry(country.iso2),
'country-item--mobile': isMobile
}"
@click="selectCountry(country)"
@touchstart="handleCountryTouch"
:ripple="isMobile"
>
<template #prepend>
<img
:src="getCountryFlagUrl(country.iso2)"
:alt="`${country.name} flag`"
class="list-flag"
:class="{ 'list-flag--mobile': isMobile }"
@error="handleFlagError"
/>
</template>
<v-list-item-title
class="country-name"
:class="{ 'country-name--mobile': isMobile }"
>
{{ country.name }}
</v-list-item-title>
<template #append>
<span
class="dial-code"
:class="{ 'dial-code--mobile': isMobile }"
>
{{ country.dialCode }}
</span>
</template>
</v-list-item>
</v-list>
<!-- Mobile Footer -->
<div v-if="isMobile" class="mobile-footer">
<v-btn
block
variant="text"
@click="closeDropdown"
class="cancel-btn"
>
Cancel
</v-btn>
</div>
</v-card>
</v-menu>
</template>
</v-text-field>
</div>
</template>
<script setup lang="ts">
import { parsePhoneNumber, AsYouType } from 'libphonenumber-js';
interface Country {
name: string;
iso2: string;
dialCode: string;
}
interface Props {
modelValue?: string;
label?: string;
placeholder?: string;
error?: boolean;
errorMessage?: string;
helpText?: string;
required?: boolean;
disabled?: boolean;
defaultCountry?: string;
preferredCountries?: string[];
}
interface Emits {
(e: 'update:modelValue', value: string): void;
(e: 'country-changed', country: Country): void;
(e: 'phone-data', data: { number: string; isValid: boolean; country: Country }): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: 'Phone number',
error: false,
required: false,
disabled: false,
defaultCountry: 'MC',
preferredCountries: () => ['MC', 'FR', 'US', 'IT', 'CH']
});
const emit = defineEmits<Emits>();
// Countries data - comprehensive list
const countries: Country[] = [
{ name: 'Monaco', iso2: 'MC', dialCode: '+377' },
{ name: 'France', iso2: 'FR', dialCode: '+33' },
{ name: 'United States', iso2: 'US', dialCode: '+1' },
{ name: 'Italy', iso2: 'IT', dialCode: '+39' },
{ name: 'Switzerland', iso2: 'CH', dialCode: '+41' },
{ name: 'United Kingdom', iso2: 'GB', dialCode: '+44' },
{ name: 'Germany', iso2: 'DE', dialCode: '+49' },
{ name: 'Spain', iso2: 'ES', dialCode: '+34' },
{ name: 'Canada', iso2: 'CA', dialCode: '+1' },
{ name: 'Australia', iso2: 'AU', dialCode: '+61' },
{ name: 'Netherlands', iso2: 'NL', dialCode: '+31' },
{ name: 'Belgium', iso2: 'BE', dialCode: '+32' },
{ name: 'Portugal', iso2: 'PT', dialCode: '+351' },
{ name: 'Austria', iso2: 'AT', dialCode: '+43' },
{ name: 'Sweden', iso2: 'SE', dialCode: '+46' },
{ name: 'Norway', iso2: 'NO', dialCode: '+47' },
{ name: 'Denmark', iso2: 'DK', dialCode: '+45' },
{ name: 'Finland', iso2: 'FI', dialCode: '+358' },
{ name: 'Ireland', iso2: 'IE', dialCode: '+353' },
{ name: 'Luxembourg', iso2: 'LU', dialCode: '+352' },
{ name: 'Japan', iso2: 'JP', dialCode: '+81' },
{ name: 'South Korea', iso2: 'KR', dialCode: '+82' },
{ name: 'Singapore', iso2: 'SG', dialCode: '+65' },
{ name: 'Hong Kong', iso2: 'HK', dialCode: '+852' },
{ name: 'New Zealand', iso2: 'NZ', dialCode: '+64' },
{ name: 'Brazil', iso2: 'BR', dialCode: '+55' },
{ name: 'Mexico', iso2: 'MX', dialCode: '+52' },
{ name: 'Argentina', iso2: 'AR', dialCode: '+54' },
{ name: 'Chile', iso2: 'CL', dialCode: '+56' },
{ name: 'South Africa', iso2: 'ZA', dialCode: '+27' }
];
// Reactive state
const dropdownOpen = ref(false);
const searchQuery = ref('');
const localNumber = ref('');
const selectedCountry = ref<Country>(
countries.find(c => c.iso2 === props.defaultCountry) || countries[0]
);
// Mobile detection
const isMobile = ref(false);
// Computed
const flagUrl = computed(() => getCountryFlagUrl(selectedCountry.value.iso2));
// Mobile detection on mount
onMounted(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768 || 'ontouchstart' in window;
};
checkMobile();
window.addEventListener('resize', checkMobile);
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
});
const filteredCountries = computed(() => {
if (!searchQuery.value) {
// Show preferred countries first, then alphabetical
const preferred = countries.filter(c => isPreferredCountry(c.iso2));
const others = countries.filter(c => !isPreferredCountry(c.iso2));
return [...preferred, ...others];
}
const query = searchQuery.value.toLowerCase();
return countries.filter(country =>
country.name.toLowerCase().includes(query) ||
country.dialCode.includes(query) ||
country.iso2.toLowerCase().includes(query)
);
});
// Methods
const getCountryFlagUrl = (iso2: string) => {
return `https://flagcdn.com/24x18/${iso2.toLowerCase()}.png`;
};
const isPreferredCountry = (iso2: string) => {
return props.preferredCountries.includes(iso2);
};
const toggleDropdown = () => {
if (!props.disabled) {
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
searchQuery.value = '';
}
}
};
const selectCountry = (country: Country) => {
selectedCountry.value = country;
dropdownOpen.value = false;
emit('country-changed', country);
// Reformat existing number with new country
if (localNumber.value) {
handleInput();
}
};
const handleInput = () => {
const rawInput = localNumber.value;
// Create full international number
const fullNumber = selectedCountry.value.dialCode + rawInput.replace(/\D/g, '');
try {
// Parse and validate
const phoneNumber = parsePhoneNumber(fullNumber);
const isValid = phoneNumber?.isValid() || false;
// Format for display (national format)
if (phoneNumber && isValid) {
const formatter = new AsYouType(selectedCountry.value.iso2 as any);
const formatted = formatter.input(rawInput);
localNumber.value = formatted;
}
// Emit data
emit('update:modelValue', fullNumber);
emit('phone-data', {
number: fullNumber,
isValid,
country: selectedCountry.value
});
} catch (error) {
// Handle invalid numbers gracefully
emit('update:modelValue', fullNumber);
emit('phone-data', {
number: fullNumber,
isValid: false,
country: selectedCountry.value
});
}
};
const handleBlur = () => {
// Additional formatting on blur if needed
};
const handleFlagError = (event: Event) => {
// Fallback to a default flag or hide image
const img = event.target as HTMLImageElement;
img.style.display = 'none';
};
// Mobile-specific handlers
const closeDropdown = () => {
dropdownOpen.value = false;
searchQuery.value = '';
};
const handleTouchStart = (event: TouchEvent) => {
// Prevent default to avoid unwanted behaviors on mobile
if (isMobile.value) {
event.preventDefault();
}
};
const handleOverlayTouch = (event: TouchEvent) => {
// Close dropdown when touching overlay
event.preventDefault();
closeDropdown();
};
const handleCountryTouch = (event: TouchEvent) => {
// Prevent default touch behaviors for better mobile experience
if (isMobile.value) {
event.preventDefault();
}
};
// Initialize from modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue && newValue !== selectedCountry.value.dialCode + localNumber.value.replace(/\D/g, '')) {
try {
const phoneNumber = parsePhoneNumber(newValue);
if (phoneNumber) {
// Find matching country
const matchingCountry = countries.find(c =>
c.dialCode === '+' + phoneNumber.countryCallingCode
);
if (matchingCountry) {
selectedCountry.value = matchingCountry;
}
// Set local number (national format)
localNumber.value = phoneNumber.formatNational().replace(phoneNumber.countryCallingCode, '').trim();
}
} catch (error) {
// Handle invalid initial value
localNumber.value = newValue;
}
}
}, { immediate: true });
</script>
<style scoped>
.phone-input-wrapper {
width: 100%;
}
.country-selector {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(var(--v-theme-surface), 1);
border: 1px solid transparent;
margin-right: 8px;
}
.country-selector:hover {
background: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.24);
}
.country-selector--open {
background: rgba(var(--v-theme-primary), 0.12);
border-color: rgba(var(--v-theme-primary), 0.48);
}
.country-flag {
width: 24px;
height: 18px;
border-radius: 2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
object-fit: cover;
}
.country-code {
font-size: 0.875rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
min-width: 32px;
}
.dropdown-icon {
transition: transform 0.2s ease;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
.dropdown-icon--rotated {
transform: rotate(180deg);
}
/* Dropdown Styling */
.country-dropdown {
min-width: 280px;
max-width: 320px;
border-radius: 8px;
overflow: hidden;
}
.search-container {
padding: 12px;
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-surface), 1);
}
.search-input :deep(.v-field) {
background: rgba(var(--v-theme-surface), 1);
}
/* Country List */
.country-list {
max-height: 240px;
overflow-y: auto;
background: rgba(var(--v-theme-surface), 1);
}
.country-list::-webkit-scrollbar {
width: 6px;
}
.country-list::-webkit-scrollbar-track {
background: transparent;
}
.country-list::-webkit-scrollbar-thumb {
background: rgba(var(--v-theme-primary), 0.3);
border-radius: 3px;
}
.country-item {
cursor: pointer;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.country-item:hover {
background: rgba(var(--v-theme-primary), 0.08) !important;
}
.country-item--selected {
background: rgba(var(--v-theme-primary), 0.12) !important;
border-left-color: rgb(var(--v-theme-primary));
font-weight: 600;
}
.country-item--preferred {
background: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
.list-flag {
width: 20px;
height: 15px;
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
object-fit: cover;
}
.country-name {
font-size: 0.875rem;
font-weight: 500;
}
.dial-code {
font-size: 0.8125rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-family: 'Roboto Mono', monospace;
}
/* Mobile Overlay */
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
backdrop-filter: blur(4px);
}
/* Mobile Header */
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-primary), 0.04);
}
.mobile-title {
font-size: 1.125rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
margin: 0;
}
.close-btn {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
}
/* Mobile Footer */
.mobile-footer {
padding: 16px 20px;
border-top: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-surface), 1);
}
.cancel-btn {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
}
/* Mobile-specific styling */
.phone-input-wrapper--mobile {
position: relative;
}
.country-selector--mobile {
padding: 6px 10px;
margin-right: 6px;
border-radius: 8px;
min-height: 44px; /* Touch-friendly size */
align-items: center;
}
.country-selector--mobile:hover {
background: rgba(var(--v-theme-primary), 0.12);
}
.country-dropdown--mobile {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: 90vw !important;
max-width: 400px !important;
max-height: 80vh !important;
border-radius: 16px !important;
z-index: 1000 !important;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2) !important;
}
.country-list--mobile {
max-height: calc(60vh - 120px) !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
.country-list--mobile::-webkit-scrollbar {
width: 8px;
}
.country-list--mobile::-webkit-scrollbar-thumb {
background: rgba(var(--v-theme-primary), 0.4);
border-radius: 4px;
}
.country-item--mobile {
min-height: 56px !important;
padding: 12px 20px !important;
border-left-width: 4px !important;
}
.country-item--mobile:active {
background: rgba(var(--v-theme-primary), 0.16) !important;
transform: scale(0.98);
}
.list-flag--mobile {
width: 24px !important;
height: 18px !important;
}
.country-name--mobile {
font-size: 1rem !important;
font-weight: 500 !important;
}
.dial-code--mobile {
font-size: 0.9375rem !important;
font-weight: 600 !important;
}
/* Touch-friendly input field */
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field) {
min-height: 56px !important;
}
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom on iOS */
padding: 16px !important;
}
/* Responsive Breakpoints */
@media (max-width: 768px) {
.country-dropdown {
min-width: 260px;
max-width: 300px;
}
.country-list {
max-height: 200px;
}
.country-selector {
min-height: 48px;
padding: 6px 10px;
}
.country-flag {
width: 24px;
height: 18px;
}
.country-code {
font-size: 0.875rem;
min-width: 36px;
}
.search-container {
padding: 16px;
}
.search-input :deep(.v-field) {
font-size: 16px !important; /* Prevent zoom */
}
}
@media (max-width: 480px) {
.country-dropdown--mobile {
width: 95vw !important;
max-height: 85vh !important;
border-radius: 12px !important;
}
.country-list--mobile {
max-height: calc(65vh - 140px) !important;
}
.country-item--mobile {
min-height: 52px !important;
padding: 10px 16px !important;
}
.mobile-header,
.mobile-footer,
.search-container {
padding: 12px 16px;
}
.mobile-title {
font-size: 1rem;
}
}
/* Landscape orientation adjustments */
@media (max-height: 500px) and (orientation: landscape) {
.country-dropdown--mobile {
max-height: 90vh !important;
width: 60vw !important;
max-width: 500px !important;
}
.country-list--mobile {
max-height: calc(75vh - 120px) !important;
}
}
/* High DPI displays */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.country-flag,
.list-flag {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* iOS specific fixes */
@supports (-webkit-touch-callout: none) {
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom on focus */
-webkit-appearance: none;
}
.country-dropdown--mobile {
-webkit-backdrop-filter: blur(8px);
}
.mobile-overlay {
-webkit-backdrop-filter: blur(4px);
}
}
/* Android specific fixes */
@media (pointer: coarse) {
.country-item--mobile {
min-height: 56px !important; /* Material Design touch target */
}
.country-selector--mobile {
min-height: 48px !important;
}
}
/* Accessibility improvements for mobile */
@media (prefers-reduced-motion: reduce) {
.country-item--mobile,
.country-selector--mobile {
transition: none !important;
}
.mobile-overlay {
backdrop-filter: none;
}
.country-dropdown--mobile {
backdrop-filter: none;
}
}
/* High contrast mode for mobile */
@media (prefers-contrast: high) {
.country-dropdown--mobile {
border: 3px solid rgb(var(--v-theme-outline));
}
.country-item--mobile {
border-bottom: 1px solid rgb(var(--v-theme-outline));
}
.mobile-header {
border-bottom: 2px solid rgb(var(--v-theme-outline));
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.country-dropdown {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.country-dropdown--mobile {
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.6) !important;
}
.mobile-overlay {
background: rgba(0, 0, 0, 0.7);
}
}
/* Safe area handling for notched devices */
@supports (padding: max(0px)) {
.country-dropdown--mobile {
padding-top: max(16px, env(safe-area-inset-top));
padding-bottom: max(16px, env(safe-area-inset-bottom));
padding-left: max(16px, env(safe-area-inset-left));
padding-right: max(16px, env(safe-area-inset-right));
}
}
</style>