From cc6ac082b1b00e22ce77aea9fd77c2aac1370d19 Mon Sep 17 00:00:00 2001 From: iamsivin Date: Tue, 12 Nov 2024 21:03:56 +0530 Subject: [PATCH] feat: Phone input --- .../Contacts/ContactsForm/ContactsForm.vue | 7 + .../dropdown-menu/DropdownMenu.vue | 8 +- .../phonenumberinput/PhoneNumberInput.vue | 231 ++++++++++++++++++ .../dashboard/i18n/locale/en/components.json | 5 + 4 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 app/javascript/dashboard/components-next/phonenumberinput/PhoneNumberInput.vue diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue index f16480999..af9a5dc96 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue @@ -8,6 +8,7 @@ import countries from 'shared/constants/countries.js'; import Input from 'dashboard/components-next/input/Input.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue'; +import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue'; const props = defineProps({ contactData: { @@ -221,6 +222,12 @@ defineExpose({ }" @update:model-value="emit('update', state)" /> + { ); }); -const handleAction = (action, value) => { - emit('action', { action, value }); +const handleAction = item => { + const { action, value, ...rest } = item; + emit('action', { action, value, ...rest }); }; onMounted(() => { @@ -79,7 +80,8 @@ onMounted(() => { 'text-n-slate-12': item.action !== 'delete', }" :disabled="item.disabled" - @click="handleAction(item.action, item.value)" + @click="handleAction(item)" + @keydown.enter="handleAction(item)" > +import { ref, computed, watch } from 'vue'; +import parsePhoneNumber from 'libphonenumber-js'; +import { useI18n } from 'vue-i18n'; +import countries from 'shared/constants/countries.js'; +import { useVuelidate } from '@vuelidate/core'; +import { required, minLength, numeric } from '@vuelidate/validators'; +import { + getActiveCountryCode, + getActiveDialCode, +} from 'shared/components/PhoneInput/helper'; + +import Input from 'dashboard/components-next/input/Input.vue'; +import Button from 'dashboard/components-next/button/Button.vue'; +import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; + +const props = defineProps({ + modelValue: { + type: [String, Number], + default: '', + }, + placeholder: { + type: String, + default: '', + }, + disabled: { + type: Boolean, + default: false, + }, +}); + +const emit = defineEmits(['update:modelValue']); + +const { t } = useI18n(); + +const phoneInputRef = ref(null); +const hasInputFocused = ref(false); +const showDropdown = ref(false); +const searchQuery = ref(''); +const activeCountryCode = ref(getActiveCountryCode()); +const activeDialCode = ref(getActiveDialCode()); +const phoneNumber = ref(props.modelValue); + +const rules = { + phoneNumber: { + minLength: minLength(2), + numeric, + }, + activeDialCode: { + required, + validDialCode: value => { + return countries.some(country => country.dial_code === value); + }, + }, +}; + +const v$ = useVuelidate(rules, { + phoneNumber, + activeDialCode, +}); + +const hasError = computed(() => v$.value.$invalid); + +const countryList = computed(() => { + return countries.map(country => ({ + value: country.id, + label: country.name, + dialCode: country.dial_code, + emoji: country.emoji, + isSelected: String(activeCountryCode.value) === String(country.id), + action: 'phoneNumberInput', + })); +}); + +const filteredCountries = computed(() => { + const query = searchQuery.value.toLowerCase(); + return countryList.value.filter(({ label, dialCode, value }) => + [label, dialCode, value].some(field => field.toLowerCase().includes(query)) + ); +}); + +const activeCountry = computed(() => + activeCountryCode.value + ? countryList.value.find( + country => country.value === activeCountryCode.value + ) + : '' +); + +const inputBorderClass = computed(() => { + if (hasError.value) { + return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8'; + } + return hasInputFocused.value + ? 'border-n-brand dark:border-n-brand' + : 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak'; +}); + +const phoneNumberError = computed(() => { + if (!v$.value.$dirty) return ''; + return v$.value.activeDialCode.$invalid + ? t('PHONE_INPUT.DIAL_CODE_ERROR') + : v$.value.phoneNumber.$invalid && t('PHONE_INPUT.ERROR'); +}); + +const emitPhoneNumber = () => { + const emitValue = phoneNumber.value + ? `${activeDialCode.value}${phoneNumber.value}` + : ''; + emit('update:modelValue', emitValue); +}; + +const onChange = async value => { + phoneNumber.value = value; + await v$.value.$touch(); + if (!v$.value.$invalid) { + emitPhoneNumber(); + } +}; + +const onBlur = () => { + hasInputFocused.value = false; +}; + +const onFocus = () => { + hasInputFocused.value = true; +}; + +const onSelectCountry = async ({ value, dialCode }) => { + if (!value || !showDropdown.value) return; + + activeCountryCode.value = value; + activeDialCode.value = dialCode; + searchQuery.value = ''; + showDropdown.value = false; + if (!v$.value.$invalid && phoneNumber.value) { + emitPhoneNumber(); + } +}; + +const toggleCountryDropdown = () => { + showDropdown.value = !showDropdown.value; + hasInputFocused.value = showDropdown.value; +}; + +const closeCountryDropdown = () => { + showDropdown.value = false; + hasInputFocused.value = false; +}; + +watch( + () => props.modelValue, + newValue => { + const number = parsePhoneNumber(newValue); + if (number) { + activeCountryCode.value = number.country; + activeDialCode.value = `+${number.countryCallingCode}`; + phoneNumber.value = newValue.replace(`+${number.countryCallingCode}`, ''); + } + }, + { immediate: true } +); + + + diff --git a/app/javascript/dashboard/i18n/locale/en/components.json b/app/javascript/dashboard/i18n/locale/en/components.json index 6f867e3d4..7a0533cef 100644 --- a/app/javascript/dashboard/i18n/locale/en/components.json +++ b/app/javascript/dashboard/i18n/locale/en/components.json @@ -18,6 +18,11 @@ "CONFIRM": "Confirm" } }, + "PHONE_INPUT": { + "SEARCH_PLACEHOLDER": "Search country", + "ERROR": "Phone number should be empty or in E.164 format", + "DIAL_CODE_ERROR": "Please select a dial code from the list" + }, "THUMBNAIL": { "AUTHOR": { "NOT_AVAILABLE": "Author is not available"