@dile/utils 3.2.3 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (17) hide show
  1. package/components/country-select/src/countriesData.js +14 -2
  2. package/components/input-phone/__screenshots__/input-phone.spec.js/dile-input-phone-Rendering-preselects-the-prefix-of-the-country-attribute-1.png +0 -0
  3. package/components/input-phone/__screenshots__/input-phone.spec.js/dile-input-phone-Value-set-from-outside-keeps-the-country-when-it-shares-the-prefix-of-the-value-1.png +0 -0
  4. package/components/input-phone/__screenshots__/input-phone.spec.js/dile-input-phone-Value-with-prefix-does-not-let-the-prefix-selector-events-escape-as-the-field-own-events-1.png +0 -0
  5. package/components/input-phone/__screenshots__/input-phone.spec.js/dile-input-phone-Value-with-prefix-updates-the-value-when-the-user-chooses-a-prefix-1.png +0 -0
  6. package/components/input-phone/index.js +1 -0
  7. package/components/input-phone/input-phone.js +7 -0
  8. package/components/input-phone/input-phone.spec.js +345 -0
  9. package/components/input-phone/src/DileInputPhone.js +294 -0
  10. package/components/phone-prefix-select/__screenshots__/phone-prefix-select.spec.js/dile-phone-prefix-select-Initial-value-and-country-ignores-a-country-that-does-not-match-the-prefix-1.png +0 -0
  11. package/components/phone-prefix-select/__screenshots__/phone-prefix-select.spec.js/dile-phone-prefix-select-Searching-compact-gives-the-selected-field-an-accessible-name-with-the-country-1.png +0 -0
  12. package/components/phone-prefix-select/index.js +10 -0
  13. package/components/phone-prefix-select/phone-prefix-select.js +7 -0
  14. package/components/phone-prefix-select/phone-prefix-select.spec.js +257 -0
  15. package/components/phone-prefix-select/src/DilePhonePrefixSelect.js +213 -0
  16. package/components/phone-prefix-select/src/phonePrefixesData.js +128 -0
  17. package/package.json +3 -3
@@ -0,0 +1,294 @@
1
+ import { html, css } from 'lit';
2
+ import { DileInput } from '@dile/ui/components/input/index.js';
3
+ import '../../phone-prefix-select/phone-prefix-select.js';
4
+ import {
5
+ getPhonePrefix,
6
+ getMainCountryForPrefix,
7
+ splitPhoneNumber,
8
+ } from '../../phone-prefix-select/src/phonePrefixesData.js';
9
+
10
+ const PREFIX_PLACEHOLDERS = {
11
+ es: 'Prefijo',
12
+ en: 'Prefix',
13
+ };
14
+
15
+ // E.164: up to 15 digits in total, prefix included.
16
+ const MAX_DIGITS = 15;
17
+ const MIN_NUMBER_DIGITS = 4;
18
+
19
+ /**
20
+ * Phone number input with an optional searchable selector of international prefixes.
21
+ *
22
+ * - With prefix (default): the value is the international number in E.164 format, without
23
+ * spaces: "+34600123456".
24
+ * - With `noPrefix`: there is no prefix selector and the value is the number typed by the user,
25
+ * without spaces or separators: "600123456".
26
+ */
27
+ export class DileInputPhone extends DileInput {
28
+
29
+ static get properties() {
30
+ return {
31
+ /** Hides the prefix selector. The value is then the plain number. */
32
+ noPrefix: { type: Boolean },
33
+ /** ISO code of the country of the prefix. Set it to preselect a default prefix. */
34
+ country: { type: String },
35
+ /** Language of the prefix selector: 'es' or 'en' */
36
+ language: { type: String },
37
+ /** ISO codes of the countries listed first in the prefix selector, separated by "|" */
38
+ priorityCountries: { type: String, attribute: 'priority-countries' },
39
+ /** Placeholder of the prefix selector */
40
+ prefixPlaceholder: { type: String },
41
+ /** Number as typed by the user, shown in the input field */
42
+ _typedNumber: { state: true },
43
+ };
44
+ }
45
+
46
+ static get styles() {
47
+ return [
48
+ super.styles,
49
+ css`
50
+ section.for-input {
51
+ gap: var(--dile-input-phone-gap, 0.5rem);
52
+ align-items: stretch;
53
+ }
54
+ dile-phone-prefix-select {
55
+ flex: 0 0 var(--dile-input-phone-prefix-width, 6.5rem);
56
+ --dile-select-overlay-min-width: var(--dile-input-phone-list-width, 16rem);
57
+ min-width: 0;
58
+ margin-bottom: 0;
59
+ }
60
+ input {
61
+ min-width: 0;
62
+ }
63
+ `
64
+ ];
65
+ }
66
+
67
+ constructor() {
68
+ super();
69
+ this.type = 'tel';
70
+ this.noPrefix = false;
71
+ this.language = 'en';
72
+ this.priorityCountries = '';
73
+ this._typedNumber = '';
74
+ this._internalValueChange = false;
75
+ }
76
+
77
+ /** Prefix of the selected country, like "+34" */
78
+ get prefix() {
79
+ return this.noPrefix ? undefined : getPhonePrefix(this.country);
80
+ }
81
+
82
+ /** Digits of the number, without the prefix */
83
+ get number() {
84
+ const raw = this._typedNumber.trim();
85
+ const split = splitPhoneNumber(raw);
86
+ return split ? split.number : raw.replace(/\D/g, '');
87
+ }
88
+
89
+ get prefixSelect() {
90
+ return this.shadowRoot.querySelector('dile-phone-prefix-select');
91
+ }
92
+
93
+ willUpdate(changedProperties) {
94
+ super.willUpdate?.(changedProperties);
95
+ if (!this.hasUpdated) {
96
+ this._initialCountry = this.country;
97
+ }
98
+ // A country set from outside changes the prefix of the number already typed.
99
+ if (this.hasUpdated && changedProperties.has('country') && !changedProperties.has('value')) {
100
+ this.updateValueFromParts();
101
+ }
102
+ if (changedProperties.has('value')) {
103
+ if (this._internalValueChange) {
104
+ this._internalValueChange = false;
105
+ } else {
106
+ this.parseValue(this.value);
107
+ }
108
+ }
109
+ }
110
+
111
+ updated(changedProperties) {
112
+ super.updated(changedProperties);
113
+ // The prefix selector only reacts to a country being set, so it has to be cleared explicitly.
114
+ if (changedProperties.has('country') && !this.country && this.prefixSelect?.value) {
115
+ this.prefixSelect.clear();
116
+ }
117
+ }
118
+
119
+ render() {
120
+ return html`
121
+ <main>
122
+ ${this.label
123
+ ? html`<label for="textField">${this.label}</label>`
124
+ : ""}
125
+ <section class="for-input">
126
+ ${this.noPrefix ? '' : this.prefixTemplate}
127
+ <input
128
+ type="tel"
129
+ inputmode="tel"
130
+ id="textField"
131
+ name="${this.name}"
132
+ placeholder="${this.placeholder}"
133
+ ?disabled="${this.disabled}"
134
+ ?readonly="${this.readonly}"
135
+ autocomplete="${this.computeAutocomplete()}"
136
+ .value="${this._typedNumber}"
137
+ class="${this.errored ? 'errored' : ''}"
138
+ @keypress="${this._lookForEnter}"
139
+ @input="${this._input}"
140
+ @blur="${this.doBlur}"
141
+ @focus="${this.doFocus}"
142
+ />
143
+ ${this.labelRight
144
+ ? html`<span class="labelright">${this.labelRight}</span>`
145
+ : ''
146
+ }
147
+ </section>
148
+ ${this.messageTemplate}
149
+ </main>
150
+ `;
151
+ }
152
+
153
+ get prefixTemplate() {
154
+ return html`
155
+ <dile-phone-prefix-select
156
+ .country=${this.country}
157
+ prefixFirst
158
+ compact
159
+ language="${this.language}"
160
+ priority-countries="${this.priorityCountries}"
161
+ placeholder="${this.prefixPlaceholder || PREFIX_PLACEHOLDERS[this.language] || PREFIX_PLACEHOLDERS.en}"
162
+ ?disabled=${this.disabled}
163
+ ?errored=${this.errored}
164
+ @dile-phone-prefix-changed=${this.onPrefixChanged}
165
+ @element-changed=${this.stopEvent}
166
+ @dile-phone-prefix-select-selected-text-changed=${this.stopEvent}
167
+ ></dile-phone-prefix-select>
168
+ `;
169
+ }
170
+
171
+ computeAutocomplete() {
172
+ if (this.disableAutocomplete) {
173
+ return 'off';
174
+ }
175
+ return this.noPrefix ? 'tel' : 'tel-national';
176
+ }
177
+
178
+ stopEvent(e) {
179
+ e.stopPropagation();
180
+ }
181
+
182
+ _input(e) {
183
+ // Only characters used to write phone numbers are allowed
184
+ const filtered = e.target.value.replace(/[^\d\s+\-().]/g, '');
185
+ if (filtered !== e.target.value) {
186
+ e.target.value = filtered;
187
+ }
188
+ this._typedNumber = filtered;
189
+ this.updateValueFromParts();
190
+ if (this.hideErrorOnInput && this.errored) {
191
+ this.clearError();
192
+ }
193
+ }
194
+
195
+ onPrefixChanged(e) {
196
+ e.stopPropagation();
197
+ const country = e.detail.country;
198
+ if (country === this.country) {
199
+ return;
200
+ }
201
+ this.country = country;
202
+ this.updateValueFromParts();
203
+ if (this.hideErrorOnInput && this.errored) {
204
+ this.clearError();
205
+ }
206
+ }
207
+
208
+ /** A full international number typed in the field is moved to the prefix selector. */
209
+ doBlur() {
210
+ if (this.noPrefix) {
211
+ return;
212
+ }
213
+ const split = splitPhoneNumber(this._typedNumber);
214
+ if (split) {
215
+ this.country = this.countryForPrefix(split.prefix);
216
+ this._typedNumber = split.number;
217
+ }
218
+ }
219
+
220
+ countryForPrefix(prefix) {
221
+ if (this.country && getPhonePrefix(this.country) === prefix) {
222
+ return this.country;
223
+ }
224
+ return getMainCountryForPrefix(prefix);
225
+ }
226
+
227
+ /** Splits a value received from outside into the prefix and the number to display. */
228
+ parseValue(value) {
229
+ const raw = value === undefined || value === null ? '' : String(value).trim();
230
+ if (!this.noPrefix) {
231
+ const split = splitPhoneNumber(raw);
232
+ if (split) {
233
+ this.country = this.countryForPrefix(split.prefix);
234
+ this._typedNumber = split.number;
235
+ return;
236
+ }
237
+ }
238
+ this._typedNumber = raw;
239
+ }
240
+
241
+ /** Builds the value from the selected prefix and the typed number. */
242
+ computePhoneValue() {
243
+ const raw = this._typedNumber.trim();
244
+ const digits = raw.replace(/\D/g, '');
245
+ if (!digits) {
246
+ return '';
247
+ }
248
+ if (raw.startsWith('+')) {
249
+ return '+' + digits;
250
+ }
251
+ if (raw.startsWith('00')) {
252
+ return '+' + digits.slice(2);
253
+ }
254
+ if (this.prefix) {
255
+ return this.prefix + digits;
256
+ }
257
+ return digits;
258
+ }
259
+
260
+ updateValueFromParts() {
261
+ const value = this.computePhoneValue();
262
+ if (value !== this.value) {
263
+ this._internalValueChange = true;
264
+ this.value = value;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Basic check of the number: an empty value is valid (use your own "required" rule).
270
+ * With prefix, the value must be international (a prefix chosen or typed) with up to
271
+ * 15 digits. Without prefix, it must have between 4 and 15 digits.
272
+ */
273
+ isValid() {
274
+ const value = this.value || '';
275
+ if (value === '') {
276
+ return true;
277
+ }
278
+ const digits = value.replace(/\D/g, '');
279
+ if (digits.length > MAX_DIGITS) {
280
+ return false;
281
+ }
282
+ if (this.noPrefix) {
283
+ return digits.length >= MIN_NUMBER_DIGITS;
284
+ }
285
+ const split = splitPhoneNumber(value);
286
+ return Boolean(split) && split.number.length >= MIN_NUMBER_DIGITS;
287
+ }
288
+
289
+ clear() {
290
+ this._typedNumber = '';
291
+ this.country = this._initialCountry;
292
+ this.updateValueFromParts();
293
+ }
294
+ }
@@ -0,0 +1,10 @@
1
+ export { DilePhonePrefixSelect } from './src/DilePhonePrefixSelect.js';
2
+ export {
3
+ PHONE_PREFIXES,
4
+ MAIN_COUNTRY_BY_PREFIX,
5
+ getPhonePrefix,
6
+ getPhonePrefixCountries,
7
+ getMainCountryForPrefix,
8
+ normalizePhonePrefix,
9
+ splitPhoneNumber,
10
+ } from './src/phonePrefixesData.js';
@@ -0,0 +1,7 @@
1
+ import { DilePhonePrefixSelect } from './src/DilePhonePrefixSelect.js';
2
+
3
+ if (!customElements.get('dile-phone-prefix-select')) {
4
+ customElements.define('dile-phone-prefix-select', DilePhonePrefixSelect);
5
+ }
6
+
7
+ export { DilePhonePrefixSelect };
@@ -0,0 +1,257 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import './phone-prefix-select.js';
3
+
4
+ describe('dile-phone-prefix-select', () => {
5
+ afterEach(() => {
6
+ document.body.innerHTML = '';
7
+ });
8
+
9
+ function wait(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+
13
+ async function render(attributes = '') {
14
+ document.body.innerHTML = `<dile-phone-prefix-select ${attributes}></dile-phone-prefix-select>`;
15
+ const el = document.body.querySelector('dile-phone-prefix-select');
16
+ await el.updateComplete;
17
+ return el;
18
+ }
19
+
20
+ async function settle(el) {
21
+ await wait(20);
22
+ await el.updateComplete;
23
+ }
24
+
25
+ async function typeKeyword(el, keyword) {
26
+ const input = el.shadowRoot.getElementById('search');
27
+ input.value = keyword;
28
+ input.dispatchEvent(new Event('input', { bubbles: true }));
29
+ await settle(el);
30
+ }
31
+
32
+ function visibleOptionTexts(el) {
33
+ return Array.from(el.select.shadowRoot.querySelectorAll('[role="option"]')).map(o => o.textContent.trim());
34
+ }
35
+
36
+ async function selectOptionByText(el, text) {
37
+ const option = Array.from(el.select.shadowRoot.querySelectorAll('[role="option"]'))
38
+ .find(o => o.textContent.trim() === text);
39
+ option.click();
40
+ await el.select.updateComplete;
41
+ await el.updateComplete;
42
+ }
43
+
44
+ describe('Rendering', () => {
45
+ it('renders the search field without requiring a slotted select', async () => {
46
+ const el = await render();
47
+ expect(el.shadowRoot.getElementById('search')).toBeTruthy();
48
+ expect(el.sourceOptions.length).toBeGreaterThan(200);
49
+ });
50
+
51
+ it('uses English texts by default and Spanish ones with language="es"', async () => {
52
+ const en = await render();
53
+ expect(en.shadowRoot.getElementById('search').placeholder).toBe('Search a country or prefix...');
54
+
55
+ const es = await render('language="es"');
56
+ expect(es.shadowRoot.getElementById('search').placeholder).toBe('Busca un país o prefijo...');
57
+ });
58
+
59
+ it('labels the clear button of the selected country in the component language', async () => {
60
+ for (const [language, expected] of [['en', 'Clear selection'], ['es', 'Borrar selección']]) {
61
+ const el = await render(`language="${language}" value="+34"`);
62
+ await el.updateComplete;
63
+ const result = el.shadowRoot.getElementById('result');
64
+ await result.updateComplete;
65
+ expect(result.shadowRoot.querySelector('button').getAttribute('aria-label')).toBe(expected);
66
+ }
67
+ });
68
+
69
+ it('keeps a custom placeholder', async () => {
70
+ const el = await render('language="es" placeholder="Prefijo"');
71
+ expect(el.shadowRoot.getElementById('search').placeholder).toBe('Prefijo');
72
+ });
73
+ });
74
+
75
+ describe('Searching', () => {
76
+ it('numeric keywords match the beginning of the prefix', async () => {
77
+ const el = await render();
78
+
79
+ await typeKeyword(el, '35');
80
+
81
+ const texts = visibleOptionTexts(el);
82
+ expect(texts).toContain('Portugal (+351)');
83
+ expect(texts).toContain('Finland (+358)');
84
+ expect(texts.every(t => /\(\+35/.test(t))).toBe(true);
85
+ });
86
+
87
+ it('accepts the leading "+" in numeric keywords', async () => {
88
+ const el = await render();
89
+
90
+ await typeKeyword(el, '+34');
91
+
92
+ expect(visibleOptionTexts(el)).toEqual(['Spain (+34)']);
93
+ });
94
+
95
+ it('text keywords match country names ignoring case and accents', async () => {
96
+ const el = await render('language="es"');
97
+
98
+ await typeKeyword(el, 'MEXICO');
99
+
100
+ expect(visibleOptionTexts(el)).toEqual(['México (+52)']);
101
+ });
102
+
103
+ it('matches alternative names of the same country', async () => {
104
+ const el = await render('language="es"');
105
+
106
+ await typeKeyword(el, 'myanmar');
107
+
108
+ expect(visibleOptionTexts(el)).toEqual(['Birmania (+95)']);
109
+ });
110
+
111
+ it('prefixFirst shows the prefix before the country name', async () => {
112
+ const el = await render('prefixFirst');
113
+
114
+ await typeKeyword(el, '+34');
115
+
116
+ expect(visibleOptionTexts(el)).toEqual(['+34 Spain']);
117
+ });
118
+
119
+ it('compact shows only the prefix once selected, keeping full texts in the list', async () => {
120
+ const el = await render('compact value="+44"');
121
+
122
+ expect(el.selectedText).toBe('+44');
123
+ expect(el.sourceOptions.find(o => o.value === 'GB').text).toBe('United Kingdom (+44)');
124
+ });
125
+
126
+ it('compact gives the selected field an accessible name with the country', async () => {
127
+ const el = await render('compact label="Prefix" value="+44"');
128
+ await el.updateComplete;
129
+ const result = el.shadowRoot.getElementById('result');
130
+ await result.updateComplete;
131
+ const input = result.shadowRoot.querySelector('input');
132
+
133
+ expect(input.value).toBe('+44');
134
+ expect(input.getAttribute('aria-label')).toBe('Prefix, United Kingdom (+44)');
135
+ });
136
+
137
+ it('lists priority countries first', async () => {
138
+ const el = await render('priority-countries="US|CA"');
139
+
140
+ await typeKeyword(el, '1');
141
+
142
+ expect(visibleOptionTexts(el).slice(0, 2)).toEqual(['United States (+1)', 'Canada (+1)']);
143
+ });
144
+ });
145
+
146
+ describe('Selecting', () => {
147
+ it('value is the prefix and country is the ISO code of the selected option', async () => {
148
+ const el = await render('name="phone_prefix"');
149
+ await typeKeyword(el, 'canada');
150
+
151
+ let changedDetail = null;
152
+ let prefixDetail = null;
153
+ el.addEventListener('element-changed', (e) => { changedDetail = e.detail; });
154
+ el.addEventListener('dile-phone-prefix-changed', (e) => { prefixDetail = e.detail; });
155
+
156
+ await selectOptionByText(el, 'Canada (+1)');
157
+
158
+ expect(el.value).toBe('+1');
159
+ expect(el.country).toBe('CA');
160
+ expect(el.selectedText).toBe('Canada (+1)');
161
+ expect(changedDetail).toEqual({ name: 'phone_prefix', value: '+1' });
162
+ expect(prefixDetail).toEqual({ value: '+1', country: 'CA' });
163
+ });
164
+
165
+ it('clearing the selection resets value and country', async () => {
166
+ const el = await render();
167
+ await typeKeyword(el, 'spain');
168
+ await selectOptionByText(el, 'Spain (+34)');
169
+
170
+ el.shadowRoot.getElementById('result').dispatchEvent(new CustomEvent('dile-input-search-cleared', { bubbles: true }));
171
+ await el.updateComplete;
172
+
173
+ expect(el.value).toBeUndefined();
174
+ expect(el.country).toBeUndefined();
175
+ expect(el.isSelected).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe('Initial value and country', () => {
180
+ it('resolves the main country of a shared prefix', async () => {
181
+ const el = await render('value="+1"');
182
+
183
+ expect(el.country).toBe('US');
184
+ expect(el.selectedText).toBe('United States (+1)');
185
+ });
186
+
187
+ it('uses the given country when it matches the prefix', async () => {
188
+ const el = await render('value="+1" country="CA"');
189
+
190
+ expect(el.country).toBe('CA');
191
+ expect(el.selectedText).toBe('Canada (+1)');
192
+ });
193
+
194
+ it('ignores a country that does not match the prefix', async () => {
195
+ const el = await render('value="+44" country="CA"');
196
+
197
+ expect(el.country).toBe('GB');
198
+ expect(el.value).toBe('+44');
199
+ });
200
+
201
+ it('accepts a prefix without "+"', async () => {
202
+ const el = await render('value="34"');
203
+
204
+ expect(el.country).toBe('ES');
205
+ expect(el.selectedText).toBe('Spain (+34)');
206
+ });
207
+
208
+ it('setting only the country selects its prefix', async () => {
209
+ const el = await render('country="MX"');
210
+ await el.updateComplete;
211
+
212
+ expect(el.value).toBe('+52');
213
+ expect(el.isSelected).toBe(true);
214
+ expect(el.selectedText).toBe('Mexico (+52)');
215
+ });
216
+
217
+ it('set() keeps the current country when it shares the new prefix', async () => {
218
+ const el = await render('value="+1" country="CA"');
219
+
220
+ el.set('+1');
221
+ await el.updateComplete;
222
+ expect(el.country).toBe('CA');
223
+
224
+ el.set('+33');
225
+ await el.updateComplete;
226
+ expect(el.country).toBe('FR');
227
+ });
228
+
229
+ it('clear() resets value and country', async () => {
230
+ const el = await render('value="+34"');
231
+
232
+ el.clear();
233
+ await el.updateComplete;
234
+
235
+ expect(el.value).toBeUndefined();
236
+ expect(el.country).toBeUndefined();
237
+ });
238
+
239
+ it('updates the selected text when the language changes', async () => {
240
+ const el = await render('value="+34"');
241
+ expect(el.selectedText).toBe('Spain (+34)');
242
+
243
+ el.language = 'es';
244
+ await el.updateComplete;
245
+
246
+ expect(el.selectedText).toBe('España (+34)');
247
+ });
248
+
249
+ it('is form-associated with the prefix as form value', async () => {
250
+ document.body.innerHTML = '<form><dile-phone-prefix-select name="prefix" value="+34"></dile-phone-prefix-select></form>';
251
+ const el = document.body.querySelector('dile-phone-prefix-select');
252
+ await el.updateComplete;
253
+
254
+ expect(new FormData(document.body.querySelector('form')).get('prefix')).toBe('+34');
255
+ });
256
+ });
257
+ });