diff --git a/package-lock.json b/package-lock.json index 71ec6014..b96c2e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "GPL-2.0-or-later", "dependencies": { "@lit/localize": "^0.12.1", + "country-list-with-dial-code-and-flag": "^5.1.1", + "i18n-iso-countries": "^7.14.0", "iconify-icon": "^1.0.2", "lit": "^3.1.4" }, @@ -6167,6 +6169,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/country-list-with-dial-code-and-flag": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/country-list-with-dial-code-and-flag/-/country-list-with-dial-code-and-flag-5.1.1.tgz", + "integrity": "sha512-dj93ztplLEV2svD4+oOKTnR+8+cQSNf8kB051w+kYIoxcW6K0BPLgWYqwJjEpvoee60FTBufmi7jvcLPEgfpjg==", + "license": "ISC" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -6421,6 +6429,12 @@ "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", "dev": true }, + "node_modules/diacritics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", + "integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==", + "license": "MIT" + }, "node_modules/diff": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", @@ -8136,6 +8150,18 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18n-iso-countries": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", + "integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==", + "license": "MIT", + "dependencies": { + "diacritics": "1.3.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/iconify-icon": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-1.0.2.tgz", diff --git a/package.json b/package.json index 82025221..89fc18ba 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ }, "dependencies": { "@lit/localize": "^0.12.1", + "country-list-with-dial-code-and-flag": "^5.1.1", + "i18n-iso-countries": "^7.14.0", "iconify-icon": "^1.0.2", "lit": "^3.1.4" }, diff --git a/phone-intl-test.html b/phone-intl-test.html new file mode 100644 index 00000000..9290bf0d --- /dev/null +++ b/phone-intl-test.html @@ -0,0 +1,47 @@ + + + + + + Phone International Test + + + + +

Phone International Component Test

+ +
+

Empty Phone International

+ +
+ +
+

Populated Phone International

+ + +
+ +
+

French Localized

+ + +
+ + \ No newline at end of file diff --git a/src/components/form/dt-multi-text/dt-multi-text.js b/src/components/form/dt-multi-text/dt-multi-text.js index 7af054c3..9a4c6ba9 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -2,6 +2,8 @@ import { html, css } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { classMap } from 'lit/directives/class-map.js'; import { when } from 'lit/directives/when.js'; +import CountryList from 'country-list-with-dial-code-and-flag'; +import { countryNames } from '../../../i18n/country-names.js'; import { DtText } from '../dt-text/dt-text.js'; import '../../icons/dt-icon.js'; @@ -81,11 +83,13 @@ export class DtMultiText extends DtText { } .field-container { display: flex; + width: 100%; } .field-container input { flex-grow: 1; } .field-container .input-addon { + flex-shrink: 0; flex-shrink: 1; display: flex; justify-content: center; @@ -144,6 +148,121 @@ export class DtMultiText extends DtText { .field-container:has(.btn-remove) ~ .icon-overlay { inset-inline-end: 5.5rem; } + + /* Phone-intl specific styles */ + .phone-intl-container { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0; + position: relative; + flex-grow: 1; + } + .phone-intl-container .country-button { + flex-shrink: 0; + min-width: 60px; + width: auto; + padding: var(--dt-form-padding, 0.5333333333rem); + border: 1px solid var(--dt-multi-text-border-color, #fefefe); + border-radius: var(--dt-multi-text-border-radius, 0); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; + background-color: var(--dt-multi-text-background-color, #fefefe); + box-shadow: var( + --dt-multi-text-box-shadow, + var( + --dt-form-input-box-shadow, + inset 0 1px 2px hsl(0deg 0% 4% / 10%) + ) + ); + font-family: inherit; + font-size: 1rem; + color: var(--dt-form-text-color, #000); + text-align: center; + height: auto; + line-height: 1.5; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + } + .phone-intl-container .country-button:disabled { + background-color: var( + --dt-text-disabled-background-color, + var(--dt-form-disabled-background-color, #e6e6e6) + ); + cursor: not-allowed; + } + .phone-intl-container .country-button:hover:not(:disabled) { + background-color: var(--dt-multi-text-background-color, #f0f0f0); + } + .phone-intl-container .country-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + background-color: var(--dt-multi-text-background-color, #fefefe); + border: 1px solid var(--dt-multi-text-border-color, #fefefe); + border-radius: var(--dt-multi-text-border-radius, 0); + box-shadow: var( + --dt-multi-text-box-shadow, + var(--dt-form-input-box-shadow, 0 2px 8px hsl(0deg 0% 4% / 20%)) + ); + max-height: 200px; + overflow-y: auto; + min-width: 250px; + display: none; + } + .phone-intl-container .country-dropdown.open { + display: block; + } + .phone-intl-container .country-option { + padding: var(--dt-form-padding, 0.5333333333rem); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + font-family: inherit; + font-size: 1rem; + color: var(--dt-form-text-color, #000); + border: none; + background: none; + width: 100%; + text-align: left; + } + .phone-intl-container .country-option:hover { + background-color: var(--dt-multi-text-background-color, #f0f0f0); + } + .phone-intl-container .country-option.selected { + background-color: var(--dt-multi-text-background-color, #e6f3ff); + } + .phone-intl-container .dial-code { + flex-shrink: 0; + padding: var(--dt-form-padding, 0.5333333333rem); + border-top: 1px solid var(--dt-multi-text-border-color, #fefefe); + border-bottom: 1px solid var(--dt-multi-text-border-color, #fefefe); + border-left: none; + border-right: none; + background-color: var(--dt-multi-text-background-color, #f8f8f8); + font-family: inherit; + font-size: 1rem; + color: var(--dt-form-text-color, #000); + font-weight: 500; + line-height: 1.5; + display: flex; + align-items: center; + min-width: 40px; + justify-content: center; + } + .phone-intl-container input[data-type='phone'] { + flex-grow: 1; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: none; + line-height: 1.5; + } `, ]; } @@ -155,9 +274,187 @@ export class DtMultiText extends DtText { type: Array, reflect: true, }, + _openDropdownKey: { + type: String, + state: true, + }, + _countryOptions: { type: Array, state: true }, + _countryOptionsLoading: { type: Boolean, state: true }, }; } + constructor() { + super(); + this._countries = CountryList.getAll(); + this._openDropdownKey = null; + this._countryOptions = []; + this._countryOptionsLoading = false; + // Bind click outside handler + this._handleClickOutside = this._handleClickOutside.bind(this); + // Do not call _loadCountryOptions here; wait for first update so locale is set + this._countryOptionsLoadedOnce = false; + } + + willUpdate(changedProps) { + super.willUpdate?.(changedProps); + // Always load country options on first update (when locale is set), or when locale changes + if (!this._countryOptionsLoadedOnce || changedProps.has('locale')) { + this._countryOptionsLoadedOnce = true; + this._loadCountryOptions(); + } + // if length of value was changed, focus the last element (which is new) + if (changedProps.has('value')) { + const old = changedProps.get('value'); + if (old && old?.length !== this.value?.length) { + this.focusNewItem(); + } + } + } + + async _loadCountryOptions() { + this._countryOptionsLoading = true; + this._countryOptions = await this._getCountryOptions(); + console.log('Country Options:', this._countryOptions); + this._countryOptionsLoading = false; + this.requestUpdate(); + } + + async _getCountryOptions() { + const locale = this.locale || 'en'; + const [isoLanguage] = locale.split('_'); + const countryData = + countryNames[isoLanguage]?.countries || countryNames.en.countries; + const collator = new Intl.Collator(locale); + const options = this._countries.map(country => { + const { data } = country; + const localizedName = countryData[data.code] || data.name; + return { + code: data.code, + name: localizedName, + dialCode: data.dial_code, + flag: data.flag, + }; + }); + options.sort((a, b) => collator.compare(a.name, b.name)); + return options; + } + + _parsePhoneValue(value) { + if (!value) return { countryCode: 'US', phoneNumber: '' }; + + // First, check if it's already in stored format: "+1 555-123-4567" or "+1 " + // This preserves formatting for already formatted numbers + const storedFormatMatch = value.match(/^(\+\d{1,4})\s+(.*)$/); + if (storedFormatMatch) { + const dialCodeToFind = storedFormatMatch[1]; + const phoneNumber = storedFormatMatch[2].trim(); // Trim to handle "+1 " case + + // Find countries by dial code + const matchingCountries = this._countries.filter( + c => c.data.dial_code === dialCodeToFind, + ); + + // Prefer the country marked as preferred, otherwise take the first one + let country = matchingCountries.find(c => c.data.preferred); + if (!country && matchingCountries.length > 0) { + [country] = matchingCountries; + } + + return { + countryCode: country ? country.data.code : 'US', + phoneNumber: phoneNumber || '', // Preserve formatting + }; + } + + // Clean the value by removing common formatting characters for unformatted numbers + const cleanValue = value.replace(/[\s\-\(\)]/g, ''); + + // Handle various international formats: + // +1 555 555 5555, +15555555555, 001 555 555 5555, 00155555555555 + let dialCodeToFind = null; + let phoneNumber = ''; + + // Check for + prefix format (most common international format) + if (cleanValue.startsWith('+')) { + // Extract potential dial codes in order of specificity: 4-digit, 3-digit, 2-digit, 1-digit + const numberPart = cleanValue.substring(1); // Remove the + + + // Try dial codes from longest to shortest for better accuracy + for (let length = 4; length >= 1; length--) { + if (numberPart.length >= length) { + const testDialCode = `+${numberPart.substring(0, length)}`; + const matchingCountries = this._countries.filter( + c => c.data.dial_code === testDialCode, + ); + + if (matchingCountries.length > 0) { + dialCodeToFind = testDialCode; + phoneNumber = numberPart.substring(length); + break; + } + } + } + } + // Check for 00 prefix format (alternative international format) + else if (cleanValue.startsWith('00')) { + const numberPart = cleanValue.substring(2); // Remove the 00 + + // Try dial codes from longest to shortest + for (let length = 4; length >= 1; length--) { + if (numberPart.length >= length) { + const testDialCode = `+${numberPart.substring(0, length)}`; + const matchingCountries = this._countries.filter( + c => c.data.dial_code === testDialCode, + ); + + if (matchingCountries.length > 0) { + dialCodeToFind = testDialCode; + phoneNumber = numberPart.substring(length); + break; + } + } + } + } + + if (dialCodeToFind) { + // Find countries by dial code + const matchingCountries = this._countries.filter( + c => c.data.dial_code === dialCodeToFind, + ); + + // Prefer the country marked as preferred, otherwise take the first one + let country = matchingCountries.find(c => c.data.preferred); + if (!country && matchingCountries.length > 0) { + [country] = matchingCountries; + } + + return { + countryCode: country ? country.data.code : 'US', + phoneNumber: phoneNumber || '', // Ensure phoneNumber is never undefined + }; + } + + // If no country code detected, assume it's just a phone number + return { countryCode: 'US', phoneNumber: cleanValue || value }; + } + + _formatPhoneValue(countryCode, phoneNumber) { + const country = this._countries.find(c => c.data.code === countryCode); + const dialCode = country ? country.data.dial_code : '+1'; + // Always include dial code for international phone numbers, even if phone number is empty + return phoneNumber ? `${dialCode} ${phoneNumber}` : `${dialCode} `; + } + + _hasActualValue(item) { + if (this.type === 'phone-intl') { + // For phone-intl, check if there's an actual phone number, not just dial code + const parsed = this._parsePhoneValue(item.value || ''); + return parsed.phoneNumber && parsed.phoneNumber.trim() !== ''; + } + // For other types, use the standard check + return !!item.value; + } + updated(changedProperties) { // if length of value was changed, focus the last element (which is new) if (changedProperties.has('value')) { @@ -168,10 +465,14 @@ export class DtMultiText extends DtText { } } + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._handleClickOutside); + } + focusNewItem() { const items = this.shadowRoot.querySelectorAll('input'); if (items && items.length) { - // console.log('trigger focus'); items[items.length - 1].focus(); } } @@ -253,6 +554,10 @@ export class DtMultiText extends DtText { } _inputFieldTemplate(item, itemCount) { + if (this.type === 'phone-intl') { + return this._phoneIntlFieldTemplate(item, itemCount); + } + return html`
${when( - itemCount > 1 || item.key || item.value, + itemCount > 1 || item.key || this._hasActualValue(item), + () => html` + + `, + () => html``, + )} + +
+ `; + } + + _phoneIntlFieldTemplate(item, itemCount) { + const parsed = this._parsePhoneValue(item.value); + const countryOptions = this._countryOptions || []; + const selectedCountry = + countryOptions.find(c => c.code === parsed.countryCode) || + countryOptions.find(c => c.code === 'US'); + const dropdownKey = item.key ?? item.tempKey; + const isDropdownOpen = this._openDropdownKey === dropdownKey; + + return html` +
+
+ +
+ ${this._countryOptionsLoading + ? html`
Loading...
` + : countryOptions.map( + country => html` + + `, + )} +
+
${selectedCountry?.dialCode || '+1'}
+ +
+ + ${when( + itemCount > 1 || item.key || this._hasActualValue(item), () => html` @@ -287,6 +680,7 @@ export class DtMultiText extends DtText { class="input-addon btn-add" @click=${this._addItem} ?disabled=${this.disabled} + type="button" > @@ -332,6 +726,193 @@ export class DtMultiText extends DtText { } } + _changeCountry(e) { + const key = e?.currentTarget?.dataset?.key; + if (key) { + const countryCode = e.target.value; + const phoneInput = e.target.parentElement.querySelector( + 'input[data-type="phone"]', + ); + const dialCodeDisplay = + e.target.parentElement.querySelector('.dial-code'); + const phoneNumber = phoneInput ? phoneInput.value : ''; + + // Update the dial code display + const countryOptions = this._countryOptions || []; + const selectedCountry = countryOptions.find(c => c.code === countryCode); + if (dialCodeDisplay && selectedCountry) { + dialCodeDisplay.textContent = selectedCountry.dialCode; + } + + const newValue = this._formatPhoneValue(countryCode, phoneNumber); + + const event = new CustomEvent('change', { + detail: { + field: this.name, + oldValue: this.value, + }, + }); + + // update this item's value in the list + this.value = this.value.map(x => ({ + ...x, + value: x.key === key || x.tempKey === key ? newValue : x.value, + })); + event.detail.newValue = this.value; + + this._setFormValue(this.value); + this.dispatchEvent(event); + } + } + + _toggleCountryDropdown(e) { + e.preventDefault(); + e.stopPropagation(); + + if (this.disabled) return; + + const { key } = e.currentTarget.dataset; + if (this._openDropdownKey === key) { + this._closeDropdown(); + } else { + this._openDropdownKey = key; + // Add click outside listener when dropdown opens + setTimeout(() => { + document.addEventListener('click', this._handleClickOutside); + }, 0); + } + } + + _selectCountry(e) { + e.preventDefault(); + e.stopPropagation(); + + const { key } = e.currentTarget.dataset; + const { countryCode } = e.currentTarget.dataset; + + if (key && countryCode) { + // Find the phone input for this item + const container = e.currentTarget.closest('.phone-intl-container'); + const phoneInput = container?.querySelector('input[data-type="phone"]'); + const phoneNumber = phoneInput ? phoneInput.value : ''; + + // Get current item to check if it's really changing + const currentItem = this.value.find(x => x.key === key || x.tempKey === key); + const currentParsed = this._parsePhoneValue(currentItem?.value || ''); + + // Update the value for this item + const newValue = this._formatPhoneValue(countryCode, phoneNumber); + + // Only trigger change event if phone number is not empty or if country actually changed + const shouldTriggerChange = phoneNumber.trim() !== '' || currentParsed.countryCode !== countryCode; + + if (shouldTriggerChange) { + const event = new CustomEvent('change', { + detail: { + field: this.name, + oldValue: this.value, + }, + }); + + // update this item's value in the list + this.value = this.value.map(x => ({ + ...x, + value: x.key === key || x.tempKey === key ? newValue : x.value, + })); + event.detail.newValue = this.value; + + this._setFormValue(this.value); + this.dispatchEvent(event); + } else { + // Just update the value without triggering change event for empty phone numbers + this.value = this.value.map(x => ({ + ...x, + value: x.key === key || x.tempKey === key ? newValue : x.value, + })); + this._setFormValue(this.value); + // Trigger a re-render to update the UI + this.requestUpdate(); + } + + // Close the dropdown and trigger a re-render to update the flag + this._closeDropdown(); + // No need to manually update dial code display; let Lit re-render + } + } + + _closeDropdown() { + this._openDropdownKey = null; + document.removeEventListener('click', this._handleClickOutside); + } + + _handleClickOutside(e) { + const dropdown = e.target.closest('.phone-intl-container'); + if (!dropdown) { + this._closeDropdown(); + } + } + + _changePhone(e) { + const key = e?.currentTarget?.dataset?.key; + if (key) { + const phoneNumber = e.target.value; + + // Get current item to preserve country code unless phone number contains a country code + const currentItem = this.value.find(x => x.key === key || x.tempKey === key); + const currentParsed = this._parsePhoneValue(currentItem?.value || ''); + let countryCode = currentParsed.countryCode; + + // Check if user entered a full international number (starts with +) + if (phoneNumber.startsWith('+')) { + const parsed = this._parsePhoneValue(phoneNumber); + if (parsed.countryCode && parsed.countryCode !== 'US') { + // User entered a full international number, use detected country + countryCode = parsed.countryCode; + // Update the phone number to just the local part + const newValue = this._formatPhoneValue(countryCode, parsed.phoneNumber); + + const event = new CustomEvent('change', { + detail: { + field: this.name, + oldValue: this.value, + }, + }); + + // update this item's value in the list + this.value = this.value.map(x => ({ + ...x, + value: x.key === key || x.tempKey === key ? newValue : x.value, + })); + event.detail.newValue = this.value; + + this._setFormValue(this.value); + this.dispatchEvent(event); + return; + } + } + + // Normal case: just update the phone number part, keep existing country + const newValue = this._formatPhoneValue(countryCode, phoneNumber); + + const event = new CustomEvent('change', { + detail: { + field: this.name, + oldValue: this.value, + }, + }); + + // update this item's value in the list + this.value = this.value.map(x => ({ + ...x, + value: x.key === key || x.tempKey === key ? newValue : x.value, + })); + event.detail.newValue = this.value; + + this._setFormValue(this.value); + this.dispatchEvent(event); + } + } + get classes() { const classes = { 'text-input': true, diff --git a/src/components/form/dt-multi-text/dt-multi-text.stories.js b/src/components/form/dt-multi-text/dt-multi-text.stories.js index 0f28c52a..244dcc2c 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.stories.js +++ b/src/components/form/dt-multi-text/dt-multi-text.stories.js @@ -22,7 +22,7 @@ export default { placeholder: { control: 'text' }, type: { control: 'select', - options: ['text', 'password', 'email', 'number', 'tel', 'url'], + options: ['text', 'password', 'email', 'number', 'tel', 'url', 'phone-intl'], defaultValue: 'text', }, disabled: { control: 'boolean' }, @@ -196,3 +196,50 @@ LocalizeRTL.args = { }, ], }; + +export const PhoneIntl = Template.bind({}); +PhoneIntl.args = { + type: 'phone-intl', + label: 'Phone Numbers', + placeholder: 'Enter phone number', + value: [ + { + verified: false, + value: '+1 555-123-4567', + key: 'phone_1', + }, + { + verified: false, + value: '+44 20 7946 0958', + key: 'phone_2', + }, + ], +}; + +export const PhoneIntlEmpty = Template.bind({}); +PhoneIntlEmpty.args = { + type: 'phone-intl', + label: 'Phone Numbers', + placeholder: 'Enter phone number', +}; + +export const PhoneIntlLocalized = Template.bind({}); +PhoneIntlLocalized.decorators = [LocaleDecorator, FormDecorator]; +PhoneIntlLocalized.args = { + type: 'phone-intl', + lang: 'fr', + label: 'Numéros de téléphone', + placeholder: 'Entrez le numéro de téléphone', + value: [ + { + verified: false, + value: '+33 1 42 86 83 26', + key: 'phone_1', + }, + { + verified: false, + value: '+49 30 12345678', + key: 'phone_2', + }, + ], +}; diff --git a/src/components/form/dt-multi-text/dt-multi-text.test.js b/src/components/form/dt-multi-text/dt-multi-text.test.js index 9aa08ea1..47b830ed 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.test.js +++ b/src/components/form/dt-multi-text/dt-multi-text.test.js @@ -1,13 +1,18 @@ import { html } from 'lit'; -import { fixture, expect, oneEvent, aTimeout, nextFrame } from '@open-wc/testing'; +import { + fixture, + expect, + oneEvent, + aTimeout, + nextFrame, +} from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import './dt-multi-text.js'; describe('DtMultiText', () => { - it('sets placeholder', async () => { const el = await fixture( - html`` + html``, ); const input = el.shadowRoot.querySelector('input'); @@ -17,41 +22,47 @@ describe('DtMultiText', () => { it('sets value from attribute', async () => { const el = await fixture( html`` + value="${JSON.stringify([ + { + key: 'cc01', + value: 'Value 1', + verified: true, + }, + { + key: 'cc02', + value: 'Value 2', + verified: true, + }, + ])}" + >`, ); const inputGroup = el.shadowRoot.querySelector('.input-group'); - expect(inputGroup.querySelector('input[data-key="cc01"]')) - .to.exist - .and.have.value('Value 1'); - expect(inputGroup.querySelector('input[data-key="cc02"]')) - .to.exist - .and.have.value('Value 2'); + expect( + inputGroup.querySelector('input[data-key="cc01"]'), + ).to.exist.and.have.value('Value 1'); + expect( + inputGroup.querySelector('input[data-key="cc02"]'), + ).to.exist.and.have.value('Value 2'); }); it('resets value', async () => { const el = await fixture( html`` + value="${JSON.stringify([ + { + key: 'cc01', + value: 'Value 1', + verified: true, + }, + { + key: 'cc02', + value: 'Value 2', + verified: true, + }, + ])}" + >`, ); el.reset(); @@ -67,9 +78,7 @@ describe('DtMultiText', () => { }); it('adds a new item on add button click', async () => { - const el = await fixture( - html`` - ); + const el = await fixture(html``); expect(el.shadowRoot.querySelectorAll('input')).to.have.length(1); @@ -84,16 +93,19 @@ describe('DtMultiText', () => { it('deletes an item on remove button click', async () => { const el = await fixture( html`` + value="${JSON.stringify([ + { + key: 'cc01', + value: 'Value 1', + verified: true, + }, + { + key: 'cc02', + value: 'Value 2', + verified: true, + }, + ])}" + >`, ); expect(el.shadowRoot.querySelectorAll('input')).to.have.length(2); @@ -121,9 +133,7 @@ describe('DtMultiText', () => { }); it('triggers a change event - item added', async () => { - const el = await fixture( - html`` - ); + const el = await fixture(html``); const input = el.shadowRoot.querySelector('input'); input.focus(); @@ -131,28 +141,30 @@ describe('DtMultiText', () => { setTimeout(async () => { await sendKeys({ type: 'Test' }); input.blur(); - }) + }); const { detail } = await oneEvent(el, 'change'); expect(detail.newValue.length).to.equal(1); - expect(detail.newValue[0].value).to.equal('Test') - + expect(detail.newValue[0].value).to.equal('Test'); }); it('triggers a change event - item removed', async () => { const el = await fixture( html`` + value="${JSON.stringify([ + { + key: 'cc01', + value: 'Value 1', + verified: true, + }, + { + key: 'cc02', + value: 'Value 2', + verified: true, + }, + ])}" + >`, ); expect(el.shadowRoot.querySelectorAll('input')).to.have.length(2); @@ -165,7 +177,141 @@ describe('DtMultiText', () => { expect(detail.oldValue).to.have.length(2); expect(detail.newValue).to.have.length(2); - expect(detail.newValue[0].value).to.equal('Value 1') + expect(detail.newValue[0].value).to.equal('Value 1'); expect(detail.newValue[0].delete).to.equal(true); }); + + describe('phone-intl type', () => { + it('renders country code dropdown for phone-intl type', async () => { + const el = await fixture( + html``, + ); + + const countryButton = el.shadowRoot.querySelector('.country-button'); + expect(countryButton).to.exist; + countryButton.click(); + await nextFrame(); + const dropdown = el.shadowRoot.querySelector('.country-dropdown'); + expect(dropdown).to.exist; + expect(dropdown.classList.contains('open')).to.be.true; + const options = dropdown.querySelectorAll('.country-option'); + expect(options.length).to.be.greaterThan(0); + }); + + it('renders phone number input for phone-intl type', async () => { + const el = await fixture( + html``, + ); + + const phoneInput = el.shadowRoot.querySelector( + 'input[data-type="phone"]', + ); + expect(phoneInput).to.exist; + }); + + it('handles phone-intl value format', async () => { + const el = await fixture( + html``, + ); + + const button = el.shadowRoot.querySelector('.country-button'); + expect(button).to.exist; + const dialCode = el.shadowRoot.querySelector('.dial-code'); + expect(dialCode).to.exist; + expect(dialCode.textContent.trim()).to.equal('+1'); + const phoneInput = el.shadowRoot.querySelector( + 'input[data-type="phone"]', + ); + expect(phoneInput.value).to.equal('555-123-4567'); + }); + + it('detects country code when entering full international number', async () => { + const el = await fixture( + html``, + ); + + await nextFrame(); // Allow country options to load + + const phoneInput = el.shadowRoot.querySelector( + 'input[data-type="phone"]', + ); + + // Simulate typing a full international number + phoneInput.value = '+961 12345678'; + phoneInput.dispatchEvent(new Event('change', { bubbles: true })); + + await nextFrame(); + + // Check that Lebanon (+961) was detected + const dialCode = el.shadowRoot.querySelector('.dial-code'); + expect(dialCode.textContent.trim()).to.equal('+961'); + expect(phoneInput.value).to.equal('12345678'); + }); + + it('allows country selection when field is empty', async () => { + const el = await fixture( + html``, + ); + + await nextFrame(); // Allow country options to load + + // Click the country button to open dropdown + const countryButton = el.shadowRoot.querySelector('.country-button'); + countryButton.click(); + await nextFrame(); + + // Select a different country (e.g., UK) + const ukOption = el.shadowRoot.querySelector( + '.country-option[data-country-code="GB"]', + ); + if (ukOption) { + ukOption.click(); + await nextFrame(); + + // Check that the dial code was updated + const dialCode = el.shadowRoot.querySelector('.dial-code'); + expect(dialCode.textContent.trim()).to.equal('+44'); + + // Check that a change event was triggered with the dial code + expect(el.value[0].value).to.equal('+44 '); + } + }); + + it('handles various international phone number formats', async () => { + const el = await fixture( + html``, + ); + + await nextFrame(); // Allow country options to load + + // Test various phone number formats + const testCases = [ + { input: '+96112345678', expectedDialCode: '+961', expectedPhone: '12345678' }, + { input: '+1 555 555 5555', expectedDialCode: '+1', expectedPhone: '555 555 5555' }, // Stored format preserves spaces + { input: '+15555555555', expectedDialCode: '+1', expectedPhone: '5555555555' }, // Compact format strips spaces + { input: '0096112345678', expectedDialCode: '+961', expectedPhone: '12345678' }, + { input: '001 555 555 5555', expectedDialCode: '+1', expectedPhone: '5555555555' }, // Cleaned format strips spaces + { input: '00155555555555', expectedDialCode: '+1', expectedPhone: '55555555555' }, + ]; + + for (const testCase of testCases) { + const parsed = el._parsePhoneValue(testCase.input); + // For +1 dial code, always expect US as it's marked as preferred + const expectedCountryCode = testCase.expectedDialCode === '+1' ? 'US' : + el._countries.find(c => c.data.dial_code === testCase.expectedDialCode)?.data?.code || 'US'; + expect(parsed.countryCode).to.equal(expectedCountryCode, `Failed for input: ${testCase.input}`); + expect(parsed.phoneNumber).to.equal(testCase.expectedPhone, `Failed for input: ${testCase.input}`); + } + }); + }); }); diff --git a/src/i18n/country-names.js b/src/i18n/country-names.js new file mode 100644 index 00000000..491f60b0 --- /dev/null +++ b/src/i18n/country-names.js @@ -0,0 +1,40 @@ +// This file statically imports all country name JSONs from i18n-iso-countries/langs/ +// and exports them as a single object keyed by language code. +// Update this list if you add/remove supported languages. + +import en from 'i18n-iso-countries/langs/en.json'; +import fr from 'i18n-iso-countries/langs/fr.json'; +import es from 'i18n-iso-countries/langs/es.json'; +import de from 'i18n-iso-countries/langs/de.json'; +import it from 'i18n-iso-countries/langs/it.json'; +import ru from 'i18n-iso-countries/langs/ru.json'; +import zh from 'i18n-iso-countries/langs/zh.json'; +import ar from 'i18n-iso-countries/langs/ar.json'; +import pt from 'i18n-iso-countries/langs/pt.json'; +import tr from 'i18n-iso-countries/langs/tr.json'; +import ja from 'i18n-iso-countries/langs/ja.json'; +import nl from 'i18n-iso-countries/langs/nl.json'; +import pl from 'i18n-iso-countries/langs/pl.json'; +import sv from 'i18n-iso-countries/langs/sv.json'; +import uk from 'i18n-iso-countries/langs/uk.json'; +import ko from 'i18n-iso-countries/langs/ko.json'; +import cs from 'i18n-iso-countries/langs/cs.json'; +import da from 'i18n-iso-countries/langs/da.json'; +import fi from 'i18n-iso-countries/langs/fi.json'; +import nb from 'i18n-iso-countries/langs/nb.json'; +import ro from 'i18n-iso-countries/langs/ro.json'; +import hu from 'i18n-iso-countries/langs/hu.json'; +import el from 'i18n-iso-countries/langs/el.json'; +import he from 'i18n-iso-countries/langs/he.json'; +import fa from 'i18n-iso-countries/langs/fa.json'; +import hi from 'i18n-iso-countries/langs/hi.json'; +import id from 'i18n-iso-countries/langs/id.json'; +import th from 'i18n-iso-countries/langs/th.json'; +import vi from 'i18n-iso-countries/langs/vi.json'; + +export const countryNames = { + en, fr, es, de, it, ru, zh, ar, pt, tr, ja, nl, pl, sv, uk, ko, cs, da, fi, nb, ro, hu, el, he, fa, hi, id, th, vi +}; + +// Usage: import { countryNames } from './country-names.js'; +// countryNames['en'].countries['US'] // "United States" diff --git a/test-phone-fixes.html b/test-phone-fixes.html new file mode 100644 index 00000000..f9654ead --- /dev/null +++ b/test-phone-fixes.html @@ -0,0 +1,90 @@ + + + + + + Phone International Component Test + + + +

Phone International Component Test

+ +
+

Test 1: Empty Field Country Selection

+

This field should allow country selection when empty and trigger change events.

+ + +
+ +
+

Test 2: Full Width Layout

+

This field should take the full width of its container.

+ + +
+ +
+

Test 3: Country Code Auto-Detection

+

Try typing "+96112345678" in the phone input - it should detect Lebanon and switch to +961.

+ + +
+ +
+

Test 4: Existing Values

+

Pre-populated values showing dial code sizing and alignment.

+ + +
+ +
+

Test 5: Lebanon Number Test

+

Testing with Lebanon (+961) dial code to check 40px min-width.

+ + +
+ + + + + \ No newline at end of file diff --git a/test-phone-intl.html b/test-phone-intl.html new file mode 100644 index 00000000..64a8ae77 --- /dev/null +++ b/test-phone-intl.html @@ -0,0 +1,68 @@ + + + + + + Phone International Test + + + + +

Phone International Component Test

+ +
+

Empty Phone International

+ + +
+ +
+

Populated Phone International

+ + +
+ +
+

French Localized Phone International

+ + +
+ + + + \ No newline at end of file diff --git a/test-phone-parsing.html b/test-phone-parsing.html new file mode 100644 index 00000000..e6aad19d --- /dev/null +++ b/test-phone-parsing.html @@ -0,0 +1,126 @@ + + + + + + Phone International Parsing Test + + + + +

Phone International Component Test

+ +

Country Code Auto-Detection Tests

+
+ +

Interactive Test

+
+

Empty Phone International

+ +
+ +
+

With Lebanon Number for Testing (+961)

+ + +
+ +

Test Instructions:

+
    +
  1. Try typing: +96112345678 in the empty field
  2. +
  3. Expected: Should detect Lebanon (+961) and separate the dial code
  4. +
  5. Try selecting different countries when the field is empty
  6. +
  7. Expected: Should not show remove button for empty phone number
  8. +
  9. Try various formats: +1 555 555 5555, +15555555555, 001 555 555 5555
  10. +
+ + + + \ No newline at end of file