@meetelise/chat 1.51.0 → 1.51.2

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.
@@ -1,22 +1,156 @@
1
+ import {
2
+ getCountries,
3
+ getCountryCallingCode,
4
+ parsePhoneNumberFromString,
5
+ } from "libphonenumber-js/min";
6
+ import type { CountryCode } from "libphonenumber-js/min";
7
+
8
+ const DEFAULT_PHONE_COUNTRY: CountryCode = "US";
9
+ const supportedCountries = new Set<string>(getCountries());
10
+
11
+ export type PhoneCountryOption = {
12
+ value: CountryCode;
13
+ label: string;
14
+ compactLabel: string;
15
+ countryName: string;
16
+ };
17
+
18
+ export const getPhoneCountry = (
19
+ country: string | null | undefined
20
+ ): CountryCode => {
21
+ const normalizedCountry = country?.toUpperCase();
22
+ return normalizedCountry && supportedCountries.has(normalizedCountry)
23
+ ? (normalizedCountry as CountryCode)
24
+ : DEFAULT_PHONE_COUNTRY;
25
+ };
26
+
27
+ const getFlagEmoji = (country: CountryCode): string =>
28
+ String.fromCodePoint(
29
+ ...[...country].map((character) => character.charCodeAt(0) + 127397)
30
+ );
31
+
32
+ const getRegionDisplayNames = (): Intl.DisplayNames | null => {
33
+ try {
34
+ return typeof Intl.DisplayNames === "function"
35
+ ? new Intl.DisplayNames(["en"], { type: "region" })
36
+ : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ };
41
+
42
+ const PINNED_PHONE_COUNTRIES: CountryCode[] = ["US", "CA", "MX", "GB"];
43
+
44
+ export const phoneCountryOptions: PhoneCountryOption[] = (() => {
45
+ const displayNames = getRegionDisplayNames();
46
+ return getCountries()
47
+ .map((country) => {
48
+ const countryName = displayNames?.of(country) ?? country;
49
+ const flag = getFlagEmoji(country);
50
+ const callingCode = getCountryCallingCode(country);
51
+ return {
52
+ value: country,
53
+ label: `${flag} +${callingCode} – ${countryName}`,
54
+ compactLabel: `${flag} +${callingCode}`,
55
+ countryName,
56
+ };
57
+ })
58
+ .sort((left, right) => {
59
+ const leftPinnedIndex = PINNED_PHONE_COUNTRIES.indexOf(left.value);
60
+ const rightPinnedIndex = PINNED_PHONE_COUNTRIES.indexOf(right.value);
61
+ if (leftPinnedIndex !== -1 || rightPinnedIndex !== -1) {
62
+ if (leftPinnedIndex === -1) return 1;
63
+ if (rightPinnedIndex === -1) return -1;
64
+ return leftPinnedIndex - rightPinnedIndex;
65
+ }
66
+ return left.countryName.localeCompare(right.countryName, "en");
67
+ });
68
+ })();
69
+
1
70
  /**
2
- * For now, only handles the US phone number case.....
3
- * Formats into phone number as you type
71
+ * Formats a phone number as the user types. Numbers beginning with `+` infer
72
+ * their country from the calling code; national numbers use `country`.
4
73
  */
5
- export const formatToPhoneInput = (phoneNumber: string): string => {
74
+ export const formatToPhoneInput = (
75
+ phoneNumber: string,
76
+ country: string | null = DEFAULT_PHONE_COUNTRY
77
+ ): string => {
78
+ const selectedCountry = getPhoneCountry(country);
79
+ const parsedPhoneNumber = parsePhoneNumberFromString(
80
+ phoneNumber,
81
+ selectedCountry
82
+ );
83
+ if (parsedPhoneNumber?.isValid()) {
84
+ return phoneNumber.trim().startsWith("+")
85
+ ? parsedPhoneNumber.formatInternational()
86
+ : parsedPhoneNumber.formatNational();
87
+ }
88
+
89
+ if (
90
+ !phoneNumber.trim().startsWith("+") &&
91
+ (selectedCountry === "US" || selectedCountry === "CA")
92
+ ) {
93
+ return formatToUsPhoneInput(phoneNumber);
94
+ }
95
+
96
+ const digits = phoneNumber.replace(/\D/g, "").substring(0, 15);
97
+ return phoneNumber.trim().startsWith("+") ? `+${digits}` : digits;
98
+ };
99
+
100
+ export const getCountryFromPhoneInput = (
101
+ phoneNumber: string,
102
+ country: string | null = DEFAULT_PHONE_COUNTRY
103
+ ): CountryCode => {
104
+ const selectedCountry = getPhoneCountry(country);
105
+ if (!phoneNumber.trim().startsWith("+")) return selectedCountry;
106
+ const parsedPhoneNumber = parsePhoneNumberFromString(
107
+ phoneNumber,
108
+ selectedCountry
109
+ );
110
+ return parsedPhoneNumber?.country ?? selectedCountry;
111
+ };
112
+
113
+ export const getNationalPhoneNumber = (
114
+ phoneNumber: string,
115
+ country: string | null = DEFAULT_PHONE_COUNTRY
116
+ ): string => {
117
+ const parsedPhoneNumber = parsePhoneNumberFromString(
118
+ phoneNumber,
119
+ getPhoneCountry(country)
120
+ );
121
+ return parsedPhoneNumber?.nationalNumber ?? phoneNumber.replace(/\D/g, "");
122
+ };
123
+
124
+ export const getE164PhoneNumber = (
125
+ phoneNumber: string,
126
+ country: string | null = DEFAULT_PHONE_COUNTRY
127
+ ): string | null => {
128
+ const parsedPhoneNumber = parsePhoneNumberFromString(
129
+ phoneNumber,
130
+ getPhoneCountry(country)
131
+ );
132
+ return parsedPhoneNumber?.isValid() ? parsedPhoneNumber.number : null;
133
+ };
134
+
135
+ export const isValidInternationalPhoneNumber = (
136
+ phoneNumber: string,
137
+ country: string | null = DEFAULT_PHONE_COUNTRY
138
+ ): boolean => getE164PhoneNumber(phoneNumber, country) !== null;
139
+
140
+ /**
141
+ * Text Us remains limited to +1 destinations because outbound SMS currently
142
+ * skips other calling codes. Keep its ten-digit behavior isolated from the
143
+ * international Tour and Email Us input.
144
+ */
145
+ export const formatToUsPhoneInput = (phoneNumber: string): string => {
6
146
  const input = phoneNumber.replace(/\D/g, "").substring(0, 10);
7
147
  const areaCode = input.substring(0, 3);
8
148
  const middle = input.substring(3, 6);
9
149
  const last = input.substring(6, 10);
10
150
 
11
- if (input.length > 5) {
12
- return `(${areaCode}) ${middle}-${last}`;
13
- }
14
- if (input.length > 2) {
15
- return `(${areaCode}) ${middle}`;
16
- }
17
- if (input.length > 0) {
18
- return `(${areaCode}`;
19
- }
151
+ if (input.length > 5) return `(${areaCode}) ${middle}-${last}`;
152
+ if (input.length > 2) return `(${areaCode}) ${middle}`;
153
+ if (input.length > 0) return `(${areaCode}`;
20
154
  return "";
21
155
  };
22
156
 
@@ -0,0 +1,308 @@
1
+ import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
2
+ import { customElement, property, state } from "lit/decorators.js";
3
+ import { classMap } from "lit/directives/class-map.js";
4
+ import { InputStyles } from "./InputStyles";
5
+ import {
6
+ formatToPhoneInput,
7
+ getCountryFromPhoneInput,
8
+ getE164PhoneNumber,
9
+ getNationalPhoneNumber,
10
+ getPhoneCountry,
11
+ phoneCountryOptions,
12
+ } from "./formatPhoneNumber";
13
+
14
+ export type InternationalPhoneChangeDetail = {
15
+ country: string;
16
+ e164: string | null;
17
+ formattedValue: string;
18
+ isValid: boolean;
19
+ };
20
+
21
+ @customElement("international-phone-input")
22
+ export class InternationalPhoneInput extends LitElement {
23
+ static styles = [
24
+ InputStyles,
25
+ css`
26
+ :host {
27
+ display: block;
28
+ width: 100%;
29
+ }
30
+
31
+ .phone-input-container {
32
+ align-items: center;
33
+ background: #d9d9d9;
34
+ border: 1px solid #efefef;
35
+ border-radius: 100px;
36
+ box-sizing: border-box;
37
+ display: flex;
38
+ min-height: 44px;
39
+ overflow: hidden;
40
+ width: 100%;
41
+ }
42
+
43
+ .phone-input-container:focus-within {
44
+ border-color: #202020;
45
+ }
46
+
47
+ .phone-input-container--invalid {
48
+ border-color: #ff0000;
49
+ }
50
+
51
+ .country-select-wrapper {
52
+ align-items: center;
53
+ box-sizing: border-box;
54
+ cursor: pointer;
55
+ display: flex;
56
+ flex: 0 0 96px;
57
+ height: 42px;
58
+ justify-content: center;
59
+ position: relative;
60
+ }
61
+
62
+ .country-select-wrapper::after {
63
+ border-bottom: 1.5px solid #4b4b4b;
64
+ border-right: 1.5px solid #4b4b4b;
65
+ content: "";
66
+ height: 5px;
67
+ margin-left: 8px;
68
+ margin-top: -3px;
69
+ transform: rotate(45deg);
70
+ width: 5px;
71
+ }
72
+
73
+ .country-select-label {
74
+ color: #202020;
75
+ font-family: "Helvetica Neue", Arial;
76
+ font-size: 14px;
77
+ font-weight: 400;
78
+ line-height: 22px;
79
+ white-space: nowrap;
80
+ }
81
+
82
+ select {
83
+ cursor: pointer;
84
+ inset: 0;
85
+ opacity: 0;
86
+ position: absolute;
87
+ width: 100%;
88
+ }
89
+
90
+ .phone-input-divider {
91
+ background: rgba(32, 32, 32, 0.18);
92
+ flex: 0 0 1px;
93
+ height: 22px;
94
+ }
95
+
96
+ .phone-input-container input.webchat-input {
97
+ background: transparent;
98
+ border: 0;
99
+ border-radius: 0;
100
+ box-sizing: border-box;
101
+ flex: 1;
102
+ min-width: 0;
103
+ padding: 10px 16px 10px 12px;
104
+ width: 100%;
105
+ }
106
+
107
+ .phone-input-container input.webchat-input:active,
108
+ .phone-input-container input.webchat-input:focus-within {
109
+ border: 0;
110
+ }
111
+
112
+ @media screen and (max-width: 767px) {
113
+ input {
114
+ font-size: 16px;
115
+ }
116
+
117
+ .country-select-wrapper {
118
+ flex-basis: 92px;
119
+ }
120
+ }
121
+ `,
122
+ ];
123
+
124
+ @property({ attribute: false })
125
+ country: string | null = null;
126
+
127
+ @property({ type: String })
128
+ value = "";
129
+
130
+ @property({ type: Boolean })
131
+ invalid = false;
132
+
133
+ @state()
134
+ private selectedCountry = "US";
135
+
136
+ protected willUpdate(changedProperties: PropertyValues<this>): void {
137
+ if (changedProperties.has("country")) {
138
+ this.selectedCountry = getPhoneCountry(this.country);
139
+ }
140
+ }
141
+
142
+ get e164(): string | null {
143
+ return getE164PhoneNumber(this.value, this.selectedCountry);
144
+ }
145
+
146
+ get isValid(): boolean {
147
+ return this.e164 !== null;
148
+ }
149
+
150
+ private dispatchPhoneChange(): void {
151
+ this.dispatchEvent(
152
+ new CustomEvent<InternationalPhoneChangeDetail>("phone-change", {
153
+ bubbles: true,
154
+ composed: true,
155
+ detail: {
156
+ country: this.selectedCountry,
157
+ e164: this.e164,
158
+ formattedValue: this.value,
159
+ isValid: this.isValid,
160
+ },
161
+ })
162
+ );
163
+ }
164
+
165
+ private setFormattedInputValue(
166
+ input: HTMLInputElement,
167
+ rawValue: string,
168
+ country: string
169
+ ): void {
170
+ const selectionStart = input.selectionStart;
171
+ const digitsAfterCursor =
172
+ selectionStart === null
173
+ ? 0
174
+ : rawValue.slice(selectionStart).replace(/\D/g, "").length;
175
+ this.value = formatToPhoneInput(rawValue, country);
176
+ input.value = this.value;
177
+
178
+ let cursorPosition = selectionStart === 0 ? 0 : this.value.length;
179
+ let remainingDigits = digitsAfterCursor;
180
+ while (remainingDigits > 0 && cursorPosition > 0) {
181
+ cursorPosition -= 1;
182
+ if (/\d/.test(this.value[cursorPosition])) remainingDigits -= 1;
183
+ }
184
+ input.setSelectionRange(cursorPosition, cursorPosition);
185
+ }
186
+
187
+ private handleInput(event: Event): void {
188
+ const input = event.target as HTMLInputElement;
189
+ const inferredCountry = getCountryFromPhoneInput(
190
+ input.value,
191
+ this.selectedCountry
192
+ );
193
+ this.selectedCountry = inferredCountry;
194
+ this.setFormattedInputValue(input, input.value, inferredCountry);
195
+ this.dispatchPhoneChange();
196
+ }
197
+
198
+ private handleCountryChange(event: Event): void {
199
+ const previousCountry = this.selectedCountry;
200
+ this.selectedCountry = (event.target as HTMLSelectElement).value;
201
+ const nationalNumber = getNationalPhoneNumber(this.value, previousCountry);
202
+ this.value = formatToPhoneInput(nationalNumber, this.selectedCountry);
203
+ this.dispatchPhoneChange();
204
+ }
205
+
206
+ private handleKeydown(event: KeyboardEvent): void {
207
+ const input = event.target as HTMLInputElement;
208
+ if (
209
+ event.key !== "Backspace" ||
210
+ input.selectionStart === null ||
211
+ input.selectionEnd !== input.selectionStart ||
212
+ input.selectionStart === 0 ||
213
+ (input.selectionStart === 1 && input.value.startsWith("+")) ||
214
+ /\d/.test(input.value[input.selectionStart - 1])
215
+ ) {
216
+ return;
217
+ }
218
+
219
+ const digitsBeforeCursor = input.value
220
+ .slice(0, input.selectionStart)
221
+ .replace(/\D/g, "");
222
+ const digitsAfterCursor = input.value
223
+ .slice(input.selectionStart)
224
+ .replace(/\D/g, "");
225
+ const internationalPrefix = input.value.trimStart().startsWith("+")
226
+ ? "+"
227
+ : "";
228
+ const digitsBeforeUpdatedCursor = digitsBeforeCursor.slice(0, -1);
229
+ const updatedValue = `${internationalPrefix}${digitsBeforeUpdatedCursor}${digitsAfterCursor}`;
230
+ const updatedCursorPosition =
231
+ internationalPrefix.length + digitsBeforeUpdatedCursor.length;
232
+ const inferredCountry = getCountryFromPhoneInput(
233
+ updatedValue,
234
+ this.selectedCountry
235
+ );
236
+ event.preventDefault();
237
+ input.setSelectionRange(updatedCursorPosition, updatedCursorPosition);
238
+ this.selectedCountry = inferredCountry;
239
+ this.setFormattedInputValue(input, updatedValue, inferredCountry);
240
+ this.dispatchPhoneChange();
241
+ }
242
+
243
+ private handleBlur(): void {
244
+ this.dispatchEvent(
245
+ new Event("phone-blur", { bubbles: true, composed: true })
246
+ );
247
+ }
248
+
249
+ private get selectedCountryLabel(): string {
250
+ return (
251
+ phoneCountryOptions.find(
252
+ (option) => option.value === this.selectedCountry
253
+ )?.compactLabel ?? this.selectedCountry
254
+ );
255
+ }
256
+
257
+ render(): TemplateResult {
258
+ return html`
259
+ <div
260
+ class=${classMap({
261
+ "phone-input-container": true,
262
+ "phone-input-container--invalid": this.invalid,
263
+ })}
264
+ >
265
+ <div class="country-select-wrapper">
266
+ <span class="country-select-label" aria-hidden="true"
267
+ >${this.selectedCountryLabel}</span
268
+ >
269
+ <select
270
+ aria-label="Country calling code"
271
+ .value=${this.selectedCountry}
272
+ @change=${this.handleCountryChange}
273
+ >
274
+ ${phoneCountryOptions.map(
275
+ (option) =>
276
+ html`<option
277
+ value=${option.value}
278
+ ?selected=${option.value === this.selectedCountry}
279
+ >
280
+ ${option.label}
281
+ </option>`
282
+ )}
283
+ </select>
284
+ </div>
285
+ <span class="phone-input-divider" aria-hidden="true"></span>
286
+ <input
287
+ class="webchat-input"
288
+ type="tel"
289
+ inputmode="tel"
290
+ placeholder="Phone"
291
+ name="phone"
292
+ autocomplete="tel-national"
293
+ maxlength="30"
294
+ .value=${this.value}
295
+ @keydown=${this.handleKeydown}
296
+ @input=${this.handleInput}
297
+ @blur=${this.handleBlur}
298
+ />
299
+ </div>
300
+ `;
301
+ }
302
+ }
303
+
304
+ declare global {
305
+ interface HTMLElementTagNameMap {
306
+ "international-phone-input": InternationalPhoneInput;
307
+ }
308
+ }
@@ -0,0 +1,91 @@
1
+ import { expect } from "@esm-bundle/chai";
2
+ import {
3
+ formatPhoneNumber,
4
+ formatToPhoneInput,
5
+ getCountryFromPhoneInput,
6
+ formatToUsPhoneInput,
7
+ getE164PhoneNumber,
8
+ getPhoneCountry,
9
+ isValidInternationalPhoneNumber,
10
+ phoneCountryOptions,
11
+ } from "./WebComponent/actions/formatPhoneNumber";
12
+
13
+ describe("international phone numbers", () => {
14
+ it("preserves and formats a UK E.164 number instead of truncating it", () => {
15
+ expect(formatToPhoneInput("+44 20 7946 0958")).to.equal("+44 20 7946 0958");
16
+ });
17
+
18
+ it("keeps existing US formatting and E.164 output", () => {
19
+ expect(formatToPhoneInput("2025550123", "US")).to.equal("(202) 555-0123");
20
+ expect(getE164PhoneNumber("(202) 555-0123", "US")).to.equal("+12025550123");
21
+ });
22
+
23
+ it("normalizes national and international UK input", () => {
24
+ expect(getE164PhoneNumber("07400 123456", "GB")).to.equal("+447400123456");
25
+ expect(getE164PhoneNumber("+44 7400 123456", "US")).to.equal(
26
+ "+447400123456"
27
+ );
28
+ });
29
+
30
+ it("handles shared calling codes and significant leading zeroes", () => {
31
+ expect(getE164PhoneNumber("416 555 0123", "CA")).to.equal("+14165550123");
32
+ expect(getE164PhoneNumber("02 1234 5678", "IT")).to.equal("+390212345678");
33
+ expect(getCountryFromPhoneInput("2025550123", "CA")).to.equal("CA");
34
+ });
35
+
36
+ it("normalizes representative national numbers across regions", () => {
37
+ const samples = [
38
+ ["US", "2025550123", "+12025550123"],
39
+ ["CA", "4165550123", "+14165550123"],
40
+ ["MX", "5512345678", "+525512345678"],
41
+ ["GB", "07400123456", "+447400123456"],
42
+ ["IT", "0212345678", "+390212345678"],
43
+ ["AU", "0412345678", "+61412345678"],
44
+ ["DE", "030123456", "+4930123456"],
45
+ ["IN", "9876543210", "+919876543210"],
46
+ ["JP", "09012345678", "+819012345678"],
47
+ ["BR", "11912345678", "+5511912345678"],
48
+ ["ZA", "0821234567", "+27821234567"],
49
+ ] as const;
50
+
51
+ for (const [country, nationalNumber, expectedE164] of samples) {
52
+ expect(getE164PhoneNumber(nationalNumber, country)).to.equal(
53
+ expectedE164,
54
+ country
55
+ );
56
+ }
57
+ });
58
+
59
+ it("rejects impossible numbers", () => {
60
+ expect(isValidInternationalPhoneNumber("123", "GB")).to.equal(false);
61
+ expect(getE164PhoneNumber("123", "GB")).to.equal(null);
62
+ });
63
+
64
+ it("offers every supported country with common regions pinned", () => {
65
+ expect(phoneCountryOptions.length).to.be.greaterThan(200);
66
+ expect(
67
+ phoneCountryOptions.slice(0, 4).map(({ value }) => value)
68
+ ).to.deep.equal(["US", "CA", "MX", "GB"]);
69
+ expect(phoneCountryOptions.find(({ value }) => value === "MX")).to.include({
70
+ compactLabel: "🇲🇽 +52",
71
+ label: "🇲🇽 +52 – Mexico",
72
+ });
73
+ expect(
74
+ new Set(phoneCountryOptions.map(({ value }) => value)).size
75
+ ).to.equal(phoneCountryOptions.length);
76
+ });
77
+
78
+ it("falls back to the US for missing or unsupported building countries", () => {
79
+ expect(getPhoneCountry(null)).to.equal("US");
80
+ expect(getPhoneCountry("unknown")).to.equal("US");
81
+ });
82
+
83
+ it("does not change the separate leasing-office display formatter", () => {
84
+ expect(formatPhoneNumber("+12025550123")).to.equal("+1 (202) 555-0123");
85
+ });
86
+
87
+ it("keeps Text Us limited to its existing ten-digit US format", () => {
88
+ expect(formatToUsPhoneInput("2025550123")).to.equal("(202) 555-0123");
89
+ expect(formatToUsPhoneInput("+44 20 7946 0958")).to.equal("(442) 079-4609");
90
+ });
91
+ });