From 2627124c97ae4575dcf9f4ee299a11a5c11eb3eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 05:18:33 +0000 Subject: [PATCH 01/14] Initial plan From 2055e698be0f8a00ab38718ae31c01f81ebc2172 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 05:22:30 +0000 Subject: [PATCH 02/14] Initial exploration and planning for phone-intl feature Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb7575bd..7f2a11dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@disciple.tools/web-components", - "version": "0.7.9", + "version": "0.7.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@disciple.tools/web-components", - "version": "0.7.9", + "version": "0.7.14", "license": "GPL-2.0-or-later", "dependencies": { "@lit/localize": "^0.12.1", From 8abb26b17876a95e7f71119604088bc1e9264708 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 05:31:43 +0000 Subject: [PATCH 03/14] Implement phone-intl feature for dt-multi-text component Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- package-lock.json | 26 +++ package.json | 2 + .../form/dt-multi-text/dt-multi-text.js | 214 ++++++++++++++++++ .../dt-multi-text/dt-multi-text.stories.js | 28 ++- .../form/dt-multi-text/dt-multi-text.test.js | 40 ++++ 5 files changed, 309 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 7f2a11dd..ba6490b8 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 ff773651..ae10fecf 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/src/components/form/dt-multi-text/dt-multi-text.js b/src/components/form/dt-multi-text/dt-multi-text.js index aee869ad..494b1373 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -4,6 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { when } from 'lit/directives/when.js'; import { DtText } from '../dt-text/dt-text.js'; import '../../icons/dt-icon.js'; +import CountryList from 'country-list-with-dial-code-and-flag'; /** * Field to edit multiple text values with ability to add/remove values. @@ -144,6 +145,43 @@ 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; + gap: 0.25rem; + } + .phone-intl-container .country-select { + flex-shrink: 0; + min-width: 120px; + 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); + 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); + } + .phone-intl-container .country-select:disabled { + background-color: var( + --dt-text-disabled-background-color, + var(--dt-form-disabled-background-color, #e6e6e6) + ); + cursor: not-allowed; + } + .phone-intl-container input[data-type="phone"] { + flex-grow: 1; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } `, ]; } @@ -158,6 +196,58 @@ export class DtMultiText extends DtText { }; } + constructor() { + super(); + this._countries = CountryList.getAll(); + } + + _getCountryOptions() { + return this._countries.map(country => { + const data = country.data; + return { + code: data.code, + name: data.name, + dialCode: data.dial_code, + flag: data.flag, + }; + }); + } + + _parsePhoneValue(value) { + if (!value) return { countryCode: 'US', phoneNumber: '' }; + + // Try to extract country code and phone number from the value + // Expected format: "+1 555-123-4567" or similar + const match = value.match(/^(\+\d{1,4})\s*(.*)$/); + if (match) { + const dialCode = match[1]; + const phoneNumber = match[2]; + + // Find countries by dial code + const matchingCountries = this._countries.filter(c => c.data.dial_code === dialCode); + + // 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[0]; + } + + return { + countryCode: country ? country.data.code : 'US', + phoneNumber: phoneNumber, + }; + } + + // If no country code detected, assume it's just a phone number + return { countryCode: 'US', phoneNumber: value }; + } + + _formatPhoneValue(countryCode, phoneNumber) { + const country = this._countries.find(c => c.data.code === countryCode); + const dialCode = country ? country.data.dial_code : '+1'; + return phoneNumber ? `${dialCode} ${phoneNumber}` : ''; + } + updated(changedProperties) { // if length of value was changed, focus the last element (which is new) if (changedProperties.has('value')) { @@ -253,6 +343,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, + () => html` + + `, + () => html``, + )} + +
+ `; + } + // rendering the input at 0 index _renderInputFields() { if (!this.value || !this.value.length) { @@ -333,6 +491,62 @@ 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 phoneNumber = phoneInput ? phoneInput.value : ''; + + 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); + } + } + + _changePhone(e) { + const key = e?.currentTarget?.dataset?.key; + if (key) { + const phoneNumber = e.target.value; + const countrySelect = e.target.parentElement.querySelector('.country-select'); + const countryCode = countrySelect ? countrySelect.value : 'US'; + + 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..2198821a 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,29 @@ 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', +}; 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..310dc74f 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 @@ -168,4 +168,44 @@ describe('DtMultiText', () => { 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 countrySelect = el.shadowRoot.querySelector('.country-select'); + expect(countrySelect).to.exist; + }); + + 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 countrySelect = el.shadowRoot.querySelector('.country-select'); + const phoneInput = el.shadowRoot.querySelector('input[data-type="phone"]'); + + expect(countrySelect.value).to.equal('US'); + expect(phoneInput.value).to.equal('555-123-4567'); + }); + }); }); From f6ac19fb43c72561e0cb5db93f09ef378a00168e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 08:30:32 +0000 Subject: [PATCH 04/14] Implement improved phone-intl UI design and localization features Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- .../form/dt-multi-text/dt-multi-text.js | 66 ++++++++++++++++--- .../dt-multi-text/dt-multi-text.stories.js | 21 ++++++ 2 files changed, 79 insertions(+), 8 deletions(-) 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 494b1373..8d596660 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -2,9 +2,10 @@ 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 countries from 'i18n-iso-countries'; import { DtText } from '../dt-text/dt-text.js'; import '../../icons/dt-icon.js'; -import CountryList from 'country-list-with-dial-code-and-flag'; /** * Field to edit multiple text values with ability to add/remove values. @@ -150,14 +151,17 @@ export class DtMultiText extends DtText { .phone-intl-container { display: flex; flex-direction: row; - gap: 0.25rem; + align-items: center; + gap: 0; } .phone-intl-container .country-select { flex-shrink: 0; - min-width: 120px; + width: 60px; 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; background-color: var(--dt-multi-text-background-color, #fefefe); box-shadow: var( --dt-multi-text-box-shadow, @@ -169,6 +173,7 @@ export class DtMultiText extends DtText { font-family: inherit; font-size: 1rem; color: var(--dt-form-text-color, #000); + text-align: center; } .phone-intl-container .country-select:disabled { background-color: var( @@ -177,10 +182,22 @@ export class DtMultiText extends DtText { ); cursor: not-allowed; } + .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); + 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; + } .phone-intl-container input[data-type="phone"] { flex-grow: 1; border-top-left-radius: 0; border-bottom-left-radius: 0; + border-left: none; } `, ]; @@ -199,14 +216,37 @@ export class DtMultiText extends DtText { constructor() { super(); this._countries = CountryList.getAll(); + + // Register some common locales with i18n-iso-countries + // We'll register them on-demand in _getCountryOptions to avoid bundling all locales } _getCountryOptions() { return this._countries.map(country => { - const data = country.data; + const { data } = country; + // Use localized country names when possible, fallback to default + let localizedName = data.name; + try { + const locale = this.locale || 'en'; + // Convert locale format: 'en_US' -> 'en', 'fr_FR' -> 'fr', etc. + const [isoLanguage] = locale.split('_'); + + // Try to get localized name, fallback to English or default + const localizedNameResult = countries.getName(data.code, isoLanguage) || + countries.getName(data.code, 'en') || + data.name; + if (localizedNameResult) { + localizedName = localizedNameResult; + } + } catch (e) { + // Fallback to default name if localization fails + // eslint-disable-next-line no-console + console.debug('Failed to get localized country name:', e); + } + return { code: data.code, - name: data.name, + name: localizedName, dialCode: data.dial_code, flag: data.flag, }; @@ -229,12 +269,12 @@ export class DtMultiText extends DtText { // 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[0]; + [country] = matchingCountries; } return { countryCode: country ? country.data.code : 'US', - phoneNumber: phoneNumber, + phoneNumber, }; } @@ -391,6 +431,7 @@ export class DtMultiText extends DtText { _phoneIntlFieldTemplate(item, itemCount) { const parsed = this._parsePhoneValue(item.value); const countryOptions = this._getCountryOptions(); + const selectedCountry = countryOptions.find(c => c.code === parsed.countryCode) || countryOptions.find(c => c.code === 'US'); return html`
@@ -407,10 +448,11 @@ export class DtMultiText extends DtText { value="${country.code}" ?selected=${country.code === parsed.countryCode} > - ${country.flag} ${country.dialCode} ${country.name} + ${country.flag} `)} +
${selectedCountry?.dialCode || '+1'}
c.code === countryCode); + if (dialCodeDisplay && selectedCountry) { + dialCodeDisplay.textContent = selectedCountry.dialCode; + } + const newValue = this._formatPhoneValue(countryCode, phoneNumber); const event = new CustomEvent('change', { 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 2198821a..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 @@ -222,3 +222,24 @@ PhoneIntlEmpty.args = { 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', + }, + ], +}; From 0eb82d59500bf9e3216f86ba575b96cd83775a04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 09:03:20 +0000 Subject: [PATCH 05/14] Enhance phone-intl UI with improved dropdown display and styling alignment Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- phone-intl-test.html | 47 +++++++++++++++++++ .../form/dt-multi-text/dt-multi-text.js | 11 ++++- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 phone-intl-test.html 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 8d596660..595f91f1 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -156,7 +156,8 @@ export class DtMultiText extends DtText { } .phone-intl-container .country-select { flex-shrink: 0; - width: 60px; + 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); @@ -174,6 +175,8 @@ export class DtMultiText extends DtText { font-size: 1rem; color: var(--dt-form-text-color, #000); text-align: center; + height: auto; + line-height: 1.5; } .phone-intl-container .country-select:disabled { background-color: var( @@ -192,12 +195,16 @@ export class DtMultiText extends DtText { font-size: 1rem; color: var(--dt-form-text-color, #000); font-weight: 500; + line-height: 1.5; + display: flex; + align-items: 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; } `, ]; @@ -448,7 +455,7 @@ export class DtMultiText extends DtText { value="${country.code}" ?selected=${country.code === parsed.countryCode} > - ${country.flag} + ${country.flag} ${country.name} ${country.dialCode} `)} From 4fd2cd3bd9e7a018beab8540a07dc61556839a89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:33:41 +0000 Subject: [PATCH 06/14] Replace HTML select with custom dropdown for phone-intl component Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- .../form/dt-multi-text/dt-multi-text.js | 182 ++++++++++++++++-- 1 file changed, 168 insertions(+), 14 deletions(-) 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 595f91f1..f15afe2e 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -153,8 +153,9 @@ export class DtMultiText extends DtText { flex-direction: row; align-items: center; gap: 0; + position: relative; } - .phone-intl-container .country-select { + .phone-intl-container .country-button { flex-shrink: 0; min-width: 60px; width: auto; @@ -177,14 +178,65 @@ export class DtMultiText extends DtText { 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-select:disabled { + .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); @@ -217,15 +269,23 @@ export class DtMultiText extends DtText { type: Array, reflect: true, }, + _openDropdownKey: { + type: String, + state: true, + }, }; } constructor() { super(); this._countries = CountryList.getAll(); + this._openDropdownKey = null; // Register some common locales with i18n-iso-countries // We'll register them on-demand in _getCountryOptions to avoid bundling all locales + + // Bind click outside handler + this._handleClickOutside = this._handleClickOutside.bind(this); } _getCountryOptions() { @@ -305,6 +365,11 @@ export class DtMultiText extends DtText { } } + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._handleClickOutside); + } + focusNewItem() { const items = this.shadowRoot.querySelectorAll('input'); if (items && items.length) { @@ -439,29 +504,41 @@ export class DtMultiText extends DtText { const parsed = this._parsePhoneValue(item.value); const countryOptions = this._getCountryOptions(); 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`
- +
${selectedCountry?.dialCode || '+1'}
@@ -494,6 +572,7 @@ export class DtMultiText extends DtText { class="input-addon btn-add" @click=${this._addItem} ?disabled=${this.disabled} + type="button" > @@ -576,6 +655,81 @@ export class DtMultiText extends DtText { } } + _toggleCountryDropdown(e) { + e.preventDefault(); + e.stopPropagation(); + + if (this.disabled) return; + + const key = e.currentTarget.dataset.key; + 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.key; + const countryCode = e.currentTarget.dataset.countryCode; + + if (key && countryCode) { + // Find the phone input and dial code display for this item + const container = e.currentTarget.closest('.phone-intl-container'); + const phoneInput = container?.querySelector('input[data-type="phone"]'); + const dialCodeDisplay = container?.querySelector('.dial-code'); + const phoneNumber = phoneInput ? phoneInput.value : ''; + + // Update the dial code display + const countryOptions = this._getCountryOptions(); + 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); + + // Close the dropdown + this._closeDropdown(); + } + } + + _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) { From 90db5dbd7d22b83ea95aeab4d07b906706f0a007 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:37:14 +0000 Subject: [PATCH 07/14] Complete phone-intl custom dropdown implementation with enhanced styling Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- .../form/dt-multi-text/dt-multi-text.js | 12 +++- test-phone-intl.html | 68 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 test-phone-intl.html 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 f15afe2e..fa2bf0d9 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -151,7 +151,7 @@ export class DtMultiText extends DtText { .phone-intl-container { display: flex; flex-direction: row; - align-items: center; + align-items: stretch; gap: 0; position: relative; } @@ -164,6 +164,7 @@ export class DtMultiText extends DtText { 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, @@ -242,6 +243,8 @@ export class DtMultiText extends DtText { 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; @@ -250,6 +253,8 @@ export class DtMultiText extends DtText { line-height: 1.5; display: flex; align-items: center; + min-width: 50px; + justify-content: center; } .phone-intl-container input[data-type="phone"] { flex-grow: 1; @@ -713,8 +718,11 @@ export class DtMultiText extends DtText { this._setFormValue(this.value); this.dispatchEvent(event); - // Close the dropdown + // Close the dropdown and trigger a re-render to update the flag this._closeDropdown(); + + // Force re-render to update the flag button + this.requestUpdate(); } } 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 From 8e9825093f943982eb2dcd321e7d99294381343b Mon Sep 17 00:00:00 2001 From: Micah Mills Date: Sat, 13 Sep 2025 14:23:55 +0300 Subject: [PATCH 08/14] Refactor phone-intl component to use localized country names and improve country options loading --- .../form/dt-multi-text/dt-multi-text.js | 181 ++++++++++-------- src/i18n/country-names.js | 40 ++++ 2 files changed, 143 insertions(+), 78 deletions(-) create mode 100644 src/i18n/country-names.js 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 fa2bf0d9..baf478d5 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -3,7 +3,7 @@ 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 countries from 'i18n-iso-countries'; +import { countryNames } from '../../../i18n/country-names.js'; import { DtText } from '../dt-text/dt-text.js'; import '../../icons/dt-icon.js'; @@ -146,7 +146,7 @@ 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; @@ -205,10 +205,7 @@ export class DtMultiText extends DtText { 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%) - ) + var(--dt-form-input-box-shadow, 0 2px 8px hsl(0deg 0% 4% / 20%)) ); max-height: 200px; overflow-y: auto; @@ -256,7 +253,7 @@ export class DtMultiText extends DtText { min-width: 50px; justify-content: center; } - .phone-intl-container input[data-type="phone"] { + .phone-intl-container input[data-type='phone'] { flex-grow: 1; border-top-left-radius: 0; border-bottom-left-radius: 0; @@ -278,6 +275,8 @@ export class DtMultiText extends DtText { type: String, state: true, }, + _countryOptions: { type: Array, state: true }, + _countryOptionsLoading: { type: Boolean, state: true }, }; } @@ -285,37 +284,47 @@ export class DtMultiText extends DtText { super(); this._countries = CountryList.getAll(); this._openDropdownKey = null; - - // Register some common locales with i18n-iso-countries - // We'll register them on-demand in _getCountryOptions to avoid bundling all locales - + 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; } - _getCountryOptions() { - return this._countries.map(country => { - const { data } = country; - // Use localized country names when possible, fallback to default - let localizedName = data.name; - try { - const locale = this.locale || 'en'; - // Convert locale format: 'en_US' -> 'en', 'fr_FR' -> 'fr', etc. - const [isoLanguage] = locale.split('_'); - - // Try to get localized name, fallback to English or default - const localizedNameResult = countries.getName(data.code, isoLanguage) || - countries.getName(data.code, 'en') || - data.name; - if (localizedNameResult) { - localizedName = localizedNameResult; - } - } catch (e) { - // Fallback to default name if localization fails - // eslint-disable-next-line no-console - console.debug('Failed to get localized country name:', e); + 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, @@ -323,33 +332,37 @@ export class DtMultiText extends DtText { flag: data.flag, }; }); + options.sort((a, b) => collator.compare(a.name, b.name)); + return options; } _parsePhoneValue(value) { if (!value) return { countryCode: 'US', phoneNumber: '' }; - + // Try to extract country code and phone number from the value // Expected format: "+1 555-123-4567" or similar const match = value.match(/^(\+\d{1,4})\s*(.*)$/); if (match) { const dialCode = match[1]; const phoneNumber = match[2]; - + // Find countries by dial code - const matchingCountries = this._countries.filter(c => c.data.dial_code === dialCode); - + const matchingCountries = this._countries.filter( + c => c.data.dial_code === dialCode, + ); + // 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, }; } - + // If no country code detected, assume it's just a phone number return { countryCode: 'US', phoneNumber: value }; } @@ -378,7 +391,6 @@ export class DtMultiText extends DtText { focusNewItem() { const items = this.shadowRoot.querySelectorAll('input'); if (items && items.length) { - // console.log('trigger focus'); items[items.length - 1].focus(); } } @@ -463,7 +475,7 @@ export class DtMultiText extends DtText { if (this.type === 'phone-intl') { return this._phoneIntlFieldTemplate(item, itemCount); } - + return html`
c.code === parsed.countryCode) || countryOptions.find(c => c.code === 'US'); + 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`
-
- ${countryOptions.map(country => html` - - `)} + ${this._countryOptionsLoading + ? html`
Loading...
` + : countryOptions.map( + country => html` + + `, + )}
${selectedCountry?.dialCode || '+1'}
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, @@ -663,10 +687,10 @@ export class DtMultiText extends DtText { _toggleCountryDropdown(e) { e.preventDefault(); e.stopPropagation(); - + if (this.disabled) return; - - const key = e.currentTarget.dataset.key; + + const { key } = e.currentTarget.dataset; if (this._openDropdownKey === key) { this._closeDropdown(); } else { @@ -681,26 +705,26 @@ export class DtMultiText extends DtText { _selectCountry(e) { e.preventDefault(); e.stopPropagation(); - + const key = e.currentTarget.dataset.key; const countryCode = e.currentTarget.dataset.countryCode; - + if (key && countryCode) { // Find the phone input and dial code display for this item const container = e.currentTarget.closest('.phone-intl-container'); const phoneInput = container?.querySelector('input[data-type="phone"]'); const dialCodeDisplay = container?.querySelector('.dial-code'); const phoneNumber = phoneInput ? phoneInput.value : ''; - + // Update the dial code display - const countryOptions = this._getCountryOptions(); + 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, @@ -717,10 +741,10 @@ export class DtMultiText extends DtText { this._setFormValue(this.value); this.dispatchEvent(event); - + // Close the dropdown and trigger a re-render to update the flag this._closeDropdown(); - + // Force re-render to update the flag button this.requestUpdate(); } @@ -742,11 +766,12 @@ export class DtMultiText extends DtText { const key = e?.currentTarget?.dataset?.key; if (key) { const phoneNumber = e.target.value; - const countrySelect = e.target.parentElement.querySelector('.country-select'); + const countrySelect = + e.target.parentElement.querySelector('.country-select'); const countryCode = countrySelect ? countrySelect.value : 'US'; - + const newValue = this._formatPhoneValue(countryCode, phoneNumber); - + const event = new CustomEvent('change', { detail: { field: this.name, 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" From 8b21e9ab2676c906cd6b4e9d7a7a743c693983c7 Mon Sep 17 00:00:00 2001 From: Micah Mills Date: Sat, 13 Sep 2025 14:44:03 +0300 Subject: [PATCH 09/14] Refactor phone input handling in DtMultiText to remove dial code display updates and rely on automatic re-rendering --- .../form/dt-multi-text/dt-multi-text.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) 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 baf478d5..5ea30fc8 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -710,19 +710,12 @@ export class DtMultiText extends DtText { const countryCode = e.currentTarget.dataset.countryCode; if (key && countryCode) { - // Find the phone input and dial code display for this item + // Find the phone input for this item const container = e.currentTarget.closest('.phone-intl-container'); const phoneInput = container?.querySelector('input[data-type="phone"]'); - const dialCodeDisplay = container?.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; - } - + // Update the value for this item const newValue = this._formatPhoneValue(countryCode, phoneNumber); const event = new CustomEvent('change', { @@ -744,9 +737,7 @@ export class DtMultiText extends DtText { // Close the dropdown and trigger a re-render to update the flag this._closeDropdown(); - - // Force re-render to update the flag button - this.requestUpdate(); + // No need to manually update dial code display; let Lit re-render } } From 0d9aa5ee795e4eac4b6fe84c488be660937659dc Mon Sep 17 00:00:00 2001 From: Micah Mills Date: Sat, 13 Sep 2025 14:52:11 +0300 Subject: [PATCH 10/14] Refactor DtMultiText tests because of updates in the render logic --- .../form/dt-multi-text/dt-multi-text.test.js | 177 ++++++++++-------- 1 file changed, 102 insertions(+), 75 deletions(-) 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 310dc74f..53601a04 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,26 +177,35 @@ 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`` + html``, ); - const countrySelect = el.shadowRoot.querySelector('.country-select'); - expect(countrySelect).to.exist; + 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`` + html``, ); - const phoneInput = el.shadowRoot.querySelector('input[data-type="phone"]'); + const phoneInput = el.shadowRoot.querySelector( + 'input[data-type="phone"]', + ); expect(phoneInput).to.exist; }); @@ -192,19 +213,25 @@ describe('DtMultiText', () => { const el = await fixture( html`` + value="${JSON.stringify([ + { + key: 'phone01', + value: '+1 555-123-4567', + countryCode: 'US', + verified: true, + }, + ])}" + >`, ); - const countrySelect = el.shadowRoot.querySelector('.country-select'); - const phoneInput = el.shadowRoot.querySelector('input[data-type="phone"]'); - - expect(countrySelect.value).to.equal('US'); + 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'); }); }); From 34b277b005847fc6d979c9ecb4df07a775ca6510 Mon Sep 17 00:00:00 2001 From: Micah Mills Date: Sat, 13 Sep 2025 19:41:32 +0300 Subject: [PATCH 11/14] eslint fixes --- src/components/form/dt-multi-text/dt-multi-text.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5ea30fc8..33d37f86 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -706,8 +706,8 @@ export class DtMultiText extends DtText { e.preventDefault(); e.stopPropagation(); - const key = e.currentTarget.dataset.key; - const countryCode = e.currentTarget.dataset.countryCode; + const { key } = e.currentTarget.dataset; + const { countryCode } = e.currentTarget.dataset; if (key && countryCode) { // Find the phone input for this item From 1aac643cb6fa066fbabfb5c2f37049c64d423078 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Sep 2025 13:04:08 +0000 Subject: [PATCH 12/14] Fix phone-intl component issues: country detection, empty field selection, styling, and dial code sizing Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- .../form/dt-multi-text/dt-multi-text.js | 50 +++++++++-- .../form/dt-multi-text/dt-multi-text.test.js | 52 +++++++++++ test-phone-fixes.html | 90 +++++++++++++++++++ 3 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 test-phone-fixes.html 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 33d37f86..500d6f65 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -83,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; @@ -154,6 +156,7 @@ export class DtMultiText extends DtText { align-items: stretch; gap: 0; position: relative; + flex-grow: 1; } .phone-intl-container .country-button { flex-shrink: 0; @@ -250,7 +253,7 @@ export class DtMultiText extends DtText { line-height: 1.5; display: flex; align-items: center; - min-width: 50px; + min-width: 40px; justify-content: center; } .phone-intl-container input[data-type='phone'] { @@ -340,11 +343,11 @@ export class DtMultiText extends DtText { if (!value) return { countryCode: 'US', phoneNumber: '' }; // Try to extract country code and phone number from the value - // Expected format: "+1 555-123-4567" or similar + // Expected format: "+1 555-123-4567" or "+1 " for empty phone number const match = value.match(/^(\+\d{1,4})\s*(.*)$/); if (match) { const dialCode = match[1]; - const phoneNumber = match[2]; + const phoneNumber = match[2].trim(); // Trim to handle "+1 " case // Find countries by dial code const matchingCountries = this._countries.filter( @@ -370,7 +373,8 @@ export class DtMultiText extends DtText { _formatPhoneValue(countryCode, phoneNumber) { const country = this._countries.find(c => c.data.code === countryCode); const dialCode = country ? country.data.dial_code : '+1'; - return phoneNumber ? `${dialCode} ${phoneNumber}` : ''; + // Always include dial code for international phone numbers, even if phone number is empty + return phoneNumber ? `${dialCode} ${phoneNumber}` : `${dialCode} `; } updated(changedProperties) { @@ -757,10 +761,42 @@ export class DtMultiText extends DtText { const key = e?.currentTarget?.dataset?.key; if (key) { const phoneNumber = e.target.value; - const countrySelect = - e.target.parentElement.querySelector('.country-select'); - const countryCode = countrySelect ? countrySelect.value : 'US'; + + // 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', { 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 53601a04..ad646c7a 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 @@ -234,5 +234,57 @@ describe('DtMultiText', () => { ); 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 '); + } + }); }); }); 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 From eefc1453bc21c04bf95e2f7d71a5b4d056098f95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:06:18 +0000 Subject: [PATCH 13/14] Fix phone-intl country code detection and empty field behavior Co-authored-by: micahmills <2531805+micahmills@users.noreply.github.com> --- .../form/dt-multi-text/dt-multi-text.js | 130 ++++++++++++++---- .../form/dt-multi-text/dt-multi-text.test.js | 25 ++++ test-phone-parsing.html | 126 +++++++++++++++++ 3 files changed, 255 insertions(+), 26 deletions(-) create mode 100644 test-phone-parsing.html 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 500d6f65..9f8a99e4 100644 --- a/src/components/form/dt-multi-text/dt-multi-text.js +++ b/src/components/form/dt-multi-text/dt-multi-text.js @@ -342,16 +342,68 @@ export class DtMultiText extends DtText { _parsePhoneValue(value) { if (!value) return { countryCode: 'US', phoneNumber: '' }; - // Try to extract country code and phone number from the value - // Expected format: "+1 555-123-4567" or "+1 " for empty phone number - const match = value.match(/^(\+\d{1,4})\s*(.*)$/); - if (match) { - const dialCode = match[1]; - const phoneNumber = match[2].trim(); // Trim to handle "+1 " case + // Clean the value by removing common formatting characters + 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; + } + } + } + } + // Check if it's already in stored format: "+1 555-123-4567" or "+1 " + else { + const match = value.match(/^(\+\d{1,4})\s*(.*)$/); + if (match) { + dialCodeToFind = match[1]; + phoneNumber = match[2].trim(); // Trim to handle "+1 " case + } + } + if (dialCodeToFind) { // Find countries by dial code const matchingCountries = this._countries.filter( - c => c.data.dial_code === dialCode, + c => c.data.dial_code === dialCodeToFind, ); // Prefer the country marked as preferred, otherwise take the first one @@ -362,12 +414,12 @@ export class DtMultiText extends DtText { return { countryCode: country ? country.data.code : 'US', - phoneNumber, + phoneNumber: phoneNumber || '', // Ensure phoneNumber is never undefined }; } // If no country code detected, assume it's just a phone number - return { countryCode: 'US', phoneNumber: value }; + return { countryCode: 'US', phoneNumber: cleanValue || value }; } _formatPhoneValue(countryCode, phoneNumber) { @@ -377,7 +429,15 @@ export class DtMultiText extends DtText { return phoneNumber ? `${dialCode} ${phoneNumber}` : `${dialCode} `; } - updated(changedProperties) { + _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; + } // if length of value was changed, focus the last element (which is new) if (changedProperties.has('value')) { const old = changedProperties.get('value'); @@ -497,7 +557,7 @@ export class DtMultiText extends DtText { /> ${when( - itemCount > 1 || item.key || item.value, + itemCount > 1 || item.key || this._hasActualValue(item), () => html`