mirror of
https://github.com/itflow-org/itflow
synced 2026-09-20 13:41:15 +00:00
Bump intl-tel-input
This commit is contained in:
148
libs/intl-tel-input/js/core/countrySearch.ts
Normal file
148
libs/intl-tel-input/js/core/countrySearch.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// * Country search & ranking logic extracted from intlTelInput.ts
|
||||
// * Maintains original comments/order. Pure functions for reuse & testability.
|
||||
|
||||
import type { Country, Iso2 } from "../data.js";
|
||||
import { normaliseString } from "../helpers/string.js";
|
||||
|
||||
export interface SearchTokens {
|
||||
normalisedName: string;
|
||||
words: string[];
|
||||
initials: string;
|
||||
dialCodePlus: string;
|
||||
}
|
||||
|
||||
export type SearchTokensMap = Map<Iso2, SearchTokens>;
|
||||
|
||||
//* Strip all non-letter chars (any script via \p{L}) so separators like "-", ".", "'", "&" all collapse to a single space.
|
||||
//* Lets queries like "St Pierre" match "St. Pierre & Miquelon", "Sant Elena" match "Sant'Elena", etc., regardless of locale.
|
||||
const normaliseName = (s: string): string =>
|
||||
normaliseString(s).replace(/[^\p{L}]+/gu, " ").trim();
|
||||
|
||||
//* Precompute country search tokens (normalised name, initials, +dialCode) to speed up filtering.
|
||||
export const buildSearchTokens = (countries: Country[]): SearchTokensMap => {
|
||||
const tokens: SearchTokensMap = new Map();
|
||||
for (const c of countries) {
|
||||
const normalisedName = normaliseName(c.name);
|
||||
const words = normalisedName.split(" ").filter(Boolean);
|
||||
const initials = words.map((w) => w[0] || "").join("");
|
||||
tokens.set(c.iso2, {
|
||||
normalisedName,
|
||||
words,
|
||||
initials,
|
||||
dialCodePlus: `+${c.dialCode}`,
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
||||
/**
|
||||
* Country search: Given raw query, return ordered list of countries by priority buckets.
|
||||
* Buckets (in order):
|
||||
* 1. exact iso2 matches
|
||||
* 2. name starts with
|
||||
* 3. name contains
|
||||
* 4. dial code exact match (bare or with plus)
|
||||
* 5. dial code contains (with plus form)
|
||||
* 6. initials match
|
||||
* 7. word fallback: any query word is a prefix of some word in the name (only when query has 2+ words AND no name bucket hit)
|
||||
* Each bucket preserves country.priority ordering.
|
||||
*/
|
||||
export const getMatchedCountries = (
|
||||
countries: Country[],
|
||||
searchTokens: SearchTokensMap,
|
||||
query: string,
|
||||
): Country[] => {
|
||||
//* lowerQuery preserves digits/+ for iso2/dial-code/initials checks; nameQuery strips non-letters for name matching.
|
||||
const lowerQuery = normaliseString(query);
|
||||
const nameQuery = normaliseName(query);
|
||||
//* Skip name buckets if the user typed only non-letters (e.g. "+44") — otherwise empty nameQuery would match everything.
|
||||
const skipNameBuckets = lowerQuery !== "" && nameQuery === "";
|
||||
|
||||
// search result groups, in order of priority
|
||||
// first, exact iso2 matches, then name starts with, then name contains, dial code match etc.
|
||||
const iso2Matches: Country[] = [];
|
||||
const nameStartsWith: Country[] = [];
|
||||
const nameContains: Country[] = [];
|
||||
const dialCodeMatches: Country[] = [];
|
||||
const dialCodeContains: Country[] = [];
|
||||
const initialsMatches: Country[] = [];
|
||||
const wordMatches: Country[] = [];
|
||||
|
||||
for (const c of countries) {
|
||||
const t = searchTokens.get(c.iso2)!;
|
||||
if (c.iso2 === lowerQuery) {
|
||||
iso2Matches.push(c);
|
||||
} else if (!skipNameBuckets && t.normalisedName.startsWith(nameQuery)) {
|
||||
nameStartsWith.push(c);
|
||||
} else if (!skipNameBuckets && t.normalisedName.includes(nameQuery)) {
|
||||
nameContains.push(c);
|
||||
} else if (
|
||||
lowerQuery === c.dialCode ||
|
||||
lowerQuery === t.dialCodePlus
|
||||
) {
|
||||
dialCodeMatches.push(c);
|
||||
} else if (t.dialCodePlus.includes(lowerQuery)) {
|
||||
dialCodeContains.push(c);
|
||||
} else if (t.initials.includes(lowerQuery)) {
|
||||
initialsMatches.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
//* Per-word fallback for multi-word queries that hit no name bucket. A country qualifies when at least one
|
||||
//* query word is the *prefix of* some word in its name (not a mid-word substring) — so "Saint Hel" finds
|
||||
//* "St. Helena" via "helena", but does not surface "Seychelles" / "St. Barthélemy" where "hel" sits mid-word.
|
||||
const queryWords = nameQuery.split(" ").filter(Boolean);
|
||||
if (
|
||||
queryWords.length > 1 &&
|
||||
iso2Matches.length === 0 &&
|
||||
nameStartsWith.length === 0 &&
|
||||
nameContains.length === 0
|
||||
) {
|
||||
const claimed = new Set<Iso2>([
|
||||
...dialCodeMatches.map((c) => c.iso2),
|
||||
...dialCodeContains.map((c) => c.iso2),
|
||||
...initialsMatches.map((c) => c.iso2),
|
||||
]);
|
||||
for (const c of countries) {
|
||||
if (claimed.has(c.iso2)) {
|
||||
continue;
|
||||
}
|
||||
const t = searchTokens.get(c.iso2)!;
|
||||
if (queryWords.some((qw) => t.words.some((sw) => sw.startsWith(qw)))) {
|
||||
wordMatches.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine result groups in correct order (and respect country priority order within each group e.g. if search +44, then UK appears first above Guernsey etc)
|
||||
const sortByPriority = (a: Country, b: Country) => a.priority - b.priority;
|
||||
|
||||
return [
|
||||
...iso2Matches,
|
||||
...nameStartsWith,
|
||||
...nameContains,
|
||||
// priority sort is only relevant when showing multiple countries with the same dial code (that's what the priority field is used to distinguish between)
|
||||
...dialCodeMatches.sort(sortByPriority),
|
||||
...dialCodeContains.sort(sortByPriority),
|
||||
...initialsMatches,
|
||||
...wordMatches,
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Hidden search (when countrySearch disabled): find first country whose name starts with query (case-insensitive).
|
||||
*/
|
||||
export const findFirstCountryStartingWith = (
|
||||
countries: Country[],
|
||||
searchTokens: SearchTokensMap,
|
||||
query: string,
|
||||
): Country | null => {
|
||||
const nameQuery = normaliseName(query);
|
||||
for (const c of countries) {
|
||||
const { normalisedName } = searchTokens.get(c.iso2)!;
|
||||
if (normalisedName.startsWith(nameQuery)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
64
libs/intl-tel-input/js/core/icons.ts
Normal file
64
libs/intl-tel-input/js/core/icons.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ARIA } from "../constants.js";
|
||||
|
||||
type SvgNode = [
|
||||
tag: string,
|
||||
attrs?: Record<string, string | number>,
|
||||
children?: SvgNode[],
|
||||
];
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const buildSvg = ([tag, attrs, children]: SvgNode): SVGElement => {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
if (attrs) {
|
||||
for (const k in attrs) {
|
||||
el.setAttribute(k, String(attrs[k]));
|
||||
}
|
||||
}
|
||||
if (children) {
|
||||
for (const c of children) {
|
||||
el.appendChild(buildSvg(c));
|
||||
}
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
/** Magnifying glass search icon */
|
||||
export const buildSearchIcon = (): SVGElement => buildSvg(
|
||||
["svg", { class: "iti__search-icon-svg", width: 14, height: 14, viewBox: "0 0 24 24", focusable: "false", [ARIA.HIDDEN]: "true" }, [
|
||||
["circle", { cx: 11, cy: 11, r: 7 }],
|
||||
["line", { x1: 21, y1: 21, x2: 16.65, y2: 16.65 }],
|
||||
]],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear (circle with X) icon
|
||||
* @param id Instance id used to create a unique mask id.
|
||||
*/
|
||||
export const buildClearIcon = (id: number): SVGElement => {
|
||||
const maskId = `iti-${id}-clear-mask`;
|
||||
return buildSvg(
|
||||
["svg", { class: "iti__search-clear-svg", width: 12, height: 12, viewBox: "0 0 16 16", [ARIA.HIDDEN]: "true", focusable: "false" }, [
|
||||
["mask", { id: maskId, maskUnits: "userSpaceOnUse" }, [
|
||||
["rect", { width: 16, height: 16, fill: "white" }],
|
||||
["path", { d: "M5.2 5.2 L10.8 10.8 M10.8 5.2 L5.2 10.8", stroke: "black", "stroke-linecap": "round", class: "iti__search-clear-x" }],
|
||||
]],
|
||||
["circle", { cx: 8, cy: 8, r: 8, class: "iti__search-clear-bg", mask: `url(#${maskId})` }],
|
||||
]],
|
||||
);
|
||||
};
|
||||
|
||||
/** Check icon shown next to the selected country in the country list. */
|
||||
export const buildCheckIcon = (): SVGElement => buildSvg(
|
||||
["svg", { class: "iti__country-check-svg", width: 14, height: 14, viewBox: "0 0 16 16", fill: "currentColor", focusable: "false", [ARIA.HIDDEN]: "true" }, [
|
||||
["path", { d: "M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z" }],
|
||||
]],
|
||||
);
|
||||
|
||||
/** Globe icon used when no country is selected */
|
||||
export const buildGlobeIcon = (): SVGElement => buildSvg(
|
||||
["svg", { width: 256, height: 256, viewBox: "0 0 512 512", class: "iti__globe-svg" }, [
|
||||
["path", { d: "M508 213a240 240 0 0 0-449-87l-2 5-2 5c-8 14-13 30-17 46a65 65 0 0 1 56 4c16-10 35-19 56-27l9-3c-6 23-10 48-10 74h-16l4 6c3 4 5 8 6 13h6c0 22 3 44 8 65l2 10-25-10-4 5 12 18 9 3 6 2 8 3 9 26 1 2 16-7h1l-5-13-1-2c24 6 49 9 75 10v26l11 10 7 7v-30l1-13c22 0 44-3 65-8l10-2-21 48-1 1a317 317 0 0 1-14 23l-21 5h-2c6 16 7 33 1 50a240 240 0 0 0 211-265m-401-56-11 6c19-44 54-79 98-98-11 20-21 44-29 69-21 6-40 15-58 23m154 182v4c-29-1-57-6-81-13-7-25-12-52-13-81h94zm0-109h-94c1-29 6-56 13-81 24-7 52-12 81-13zm0-112c-22 1-44 4-65 8l-10 2 12-30 9-17 1-2a332 332 0 0 1 13-23c13-4 26-6 40-7zm187 69 6 4c4 12 6 25 6 38v1h-68c-1-26-4-51-10-74l48 20 1 1 14 8zm-14-44 10 20c-20-11-43-21-68-29-8-25-18-49-29-69 37 16 67 44 87 78M279 49h1c13 1 27 3 39 7l14 23 1 2a343 343 0 0 1 12 26l2 5 6 16c-23-6-48-9-74-10h-1zm0 87h1c29 1 56 6 81 13 7 24 12 51 12 80v1h-94zm2 207h-2v-94h95c-1 29-6 56-13 81-24 7-51 12-80 13m86 60-20 10c11-20 21-43 29-68 25-8 48-18 68-29-16 37-43 67-77 87m87-115-7 5-16 9-2 1a337 337 0 0 1-47 21c6-24 9-49 10-75h68c0 13-2 27-6 39" }],
|
||||
["path", { d: "m261 428-2-2-22-21a40 40 0 0 0-32-11h-1a37 37 0 0 0-18 8l-1 1-4 2-2 2-5 4c-9-3-36-31-47-44s-32-45-34-55l3-2a151 151 0 0 0 11-9v-1a39 39 0 0 0 5-48l-3-3-11-19-3-4-5-7h-1l-3-3-4-3-5-2a35 35 0 0 0-16-3h-5c-4 1-14 5-24 11l-4 2-4 3-4 2c-9 8-17 17-18 27a380 380 0 0 0 212 259h3c12 0 25-10 36-21l10-12 6-11a39 39 0 0 0-8-40" }],
|
||||
]],
|
||||
);
|
||||
64
libs/intl-tel-input/js/core/numerals.ts
Normal file
64
libs/intl-tel-input/js/core/numerals.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export class Numerals {
|
||||
#userNumeralSet: "ascii" | "arabic-indic" | "persian" | undefined;
|
||||
|
||||
//* Stateless conversion of any Arabic-Indic / Persian digits to ASCII 0-9.
|
||||
//* Use this when you need to normalise digits without affecting any instance's tracked numeral set (e.g. for the country-search query).
|
||||
public static toAscii(str: string): string {
|
||||
if (!str) {
|
||||
return "";
|
||||
}
|
||||
// Note: yes it runs two replaces, but a replace call is really a test + manipulate, so for standard ascii input, it just runs 2x tests. Not worth splitting up and adding test guards.
|
||||
return str
|
||||
.replace(/[٠-٩]/g, (ch) =>
|
||||
String.fromCharCode(0x30 + (ch.charCodeAt(0) - 0x0660)),
|
||||
)
|
||||
.replace(/[۰-۹]/g, (ch) =>
|
||||
String.fromCharCode(0x30 + (ch.charCodeAt(0) - 0x06f0)),
|
||||
);
|
||||
}
|
||||
|
||||
constructor(initialValue: string) {
|
||||
if (initialValue) {
|
||||
this.#updateNumeralSet(initialValue);
|
||||
}
|
||||
}
|
||||
|
||||
// If any Arabic-Indic digits, then label it as that set. Same for Persian. Otherwise assume ASCII.
|
||||
#updateNumeralSet(str: string): void {
|
||||
if (/[٠-٩]/.test(str)) {
|
||||
this.#userNumeralSet = "arabic-indic";
|
||||
} else if (/[۰-۹]/.test(str)) {
|
||||
this.#userNumeralSet = "persian";
|
||||
} else {
|
||||
this.#userNumeralSet = "ascii";
|
||||
}
|
||||
}
|
||||
|
||||
// Denormalise ASCII 0-9 to the user's numeral set. If not yet known, return as-is.
|
||||
// NOTE: normalise is always called before this, so it should be impossible for the numeral set to be unknown at this point.
|
||||
public denormalise(str: string): string {
|
||||
if (!this.#userNumeralSet || this.#userNumeralSet === "ascii") {
|
||||
return str;
|
||||
}
|
||||
const base = this.#userNumeralSet === "arabic-indic" ? 0x0660 : 0x06f0;
|
||||
return str.replace(/[0-9]/g, (d) => String.fromCharCode(base + Number(d)));
|
||||
}
|
||||
|
||||
// Normalize Eastern Arabic (U+0660-0669) and Persian/Extended Arabic-Indic (U+06F0-06F9) numerals to ASCII 0-9.
|
||||
// Tracks the user's numeral set as a side effect so denormalise can mirror it back.
|
||||
public normalise(str: string): string {
|
||||
if (!str) {
|
||||
return "";
|
||||
}
|
||||
this.#updateNumeralSet(str);
|
||||
//* Fast-path ASCII (the common case) so we skip both replaces on every keystroke.
|
||||
if (this.#userNumeralSet === "ascii") {
|
||||
return str;
|
||||
}
|
||||
return Numerals.toAscii(str);
|
||||
}
|
||||
|
||||
public isAscii(): boolean {
|
||||
return !this.#userNumeralSet || this.#userNumeralSet === "ascii";
|
||||
}
|
||||
}
|
||||
466
libs/intl-tel-input/js/core/options.ts
Normal file
466
libs/intl-tel-input/js/core/options.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
PLACEHOLDER_POLICY,
|
||||
NUMBER_FORMAT,
|
||||
NUMBER_TYPE,
|
||||
NUMBER_TYPES,
|
||||
COUNTRY_SELECTOR_MODE,
|
||||
COUNTRY_SELECTOR_MODES,
|
||||
ITI_SLOTS,
|
||||
LAYOUT,
|
||||
} from "../constants.js";
|
||||
import defaultEnglishStrings from "../locale/en.js";
|
||||
import { isIso2, type Iso2 } from "../data.js";
|
||||
import type { AllOptions, SomeOptions } from "../types/public-api.js";
|
||||
|
||||
// Helper for media query evaluation
|
||||
const mediaQuery = (q: string): boolean =>
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia(q).matches;
|
||||
|
||||
const isNarrowViewport = () =>
|
||||
mediaQuery(`(max-width: ${LAYOUT.NARROW_VIEWPORT_WIDTH}px)`);
|
||||
|
||||
//* Heuristic for resolving countrySelectorMode "AUTO" to "FULLSCREEN" or "DROPDOWN".
|
||||
const resolveAutoCountrySelectorMode = ():
|
||||
| typeof COUNTRY_SELECTOR_MODE.FULLSCREEN
|
||||
| typeof COUNTRY_SELECTOR_MODE.DROPDOWN => {
|
||||
if (typeof navigator !== "undefined" && typeof window !== "undefined") {
|
||||
const isShortViewport = mediaQuery("(max-height: 600px)");
|
||||
const isCoarsePointer = mediaQuery("(pointer: coarse)");
|
||||
/* Heuristic rationale: If narrow width OR (coarse pointer with constrained height) we prefer fullscreen for usability. Coarse pointer usually implies touch (phones/tablets, some hybrids) where larger touch targets help (and virtual keyboards may be used, which consume more vertical space). */
|
||||
if (isNarrowViewport() || (isCoarsePointer && isShortViewport)) {
|
||||
return COUNTRY_SELECTOR_MODE.FULLSCREEN;
|
||||
}
|
||||
}
|
||||
return COUNTRY_SELECTOR_MODE.DROPDOWN;
|
||||
};
|
||||
|
||||
export const defaults: AllOptions = {
|
||||
//* How the country selector is displayed. "DROPDOWN" vs "FULLSCREEN", or "AUTO" to decide itself, or "OFF".
|
||||
countrySelectorMode: COUNTRY_SELECTOR_MODE.AUTO,
|
||||
//* The number type to enforce during validation.
|
||||
allowedNumberTypes: [NUMBER_TYPE.MOBILE, NUMBER_TYPE.FIXED_LINE],
|
||||
//* Whether or not to allow extensions after the main number.
|
||||
allowNumberExtensions: false,
|
||||
// Allow alphanumeric "phonewords" (e.g. +1 800 FLOWERS) as valid numbers
|
||||
allowPhonewords: false,
|
||||
//* Add custom classes to the elements we generate, keyed by slot name e.g. { selectedCountry: "rounded-l-lg" }.
|
||||
classNames: {},
|
||||
//* Add a custom class to the (injected) container element.
|
||||
containerClass: "",
|
||||
//* Locale for localising country names via Intl.DisplayNames.
|
||||
countryNameLocale: "en",
|
||||
//* Override individual country names by iso2 code.
|
||||
countryNameOverrides: {},
|
||||
//* The order of the countries in the country list. Defaults to alphabetical.
|
||||
countryOrder: null,
|
||||
//* Add a country search input at the top of the country selector.
|
||||
countrySearch: true,
|
||||
//* Modify the auto placeholder.
|
||||
customPlaceholder: null,
|
||||
//* Always show the dropdown
|
||||
dropdownAlwaysOpen: false,
|
||||
//* Optional DOM element to append the dropdown to (used to escape ancestors with overflow:hidden, or to mount in a custom container). Only consulted in dropdown rendering; ignored when the country selector renders as a fullscreen popup.
|
||||
dropdownParent: null,
|
||||
//* Don't display these countries.
|
||||
excludeCountries: null,
|
||||
//* Fix the dropdown width to the input width (rather than being as wide as the longest country name).
|
||||
matchDropdownWidth: true,
|
||||
//* Format the number as the user types
|
||||
formatAsYouType: true,
|
||||
//* Inject hidden inputs with the names returned from this function, and on submit, populate them with the full number and selected country iso2.
|
||||
hiddenInputs: null,
|
||||
//* Translations for the core library UI strings e.g. search input placeholder, country names.
|
||||
uiTranslations: {},
|
||||
//* Initial country.
|
||||
initialCountry: "",
|
||||
//* Async lookup function used to determine the initial country (e.g. via IP). Ignored if initialCountry is set.
|
||||
initialCountryLookup: null,
|
||||
//* A function to load the utils script.
|
||||
loadUtils: null,
|
||||
//* Format used when displaying numbers (placeholder examples and stored values). One of "E164", "INTERNATIONAL", "NATIONAL".
|
||||
numberDisplayFormat: NUMBER_FORMAT.INTERNATIONAL,
|
||||
//* Display only these countries.
|
||||
onlyCountries: null,
|
||||
//* When to set the placeholder to an example number for the selected country: "POLITE" only when the input has no manually-set placeholder, "AGGRESSIVE" always, "OFF" never.
|
||||
placeholderNumberPolicy: PLACEHOLDER_POLICY.POLITE,
|
||||
//* Number type to use for placeholders.
|
||||
placeholderNumberType: NUMBER_TYPE.MOBILE,
|
||||
//* Add custom classes to the search input element.
|
||||
searchInputClass: "",
|
||||
//* Display the international dial code next to the selected flag.
|
||||
separateDialCode: true,
|
||||
//* When strictMode rejects a key (etc), play a short feedback animation
|
||||
strictRejectAnimation: true,
|
||||
//* Show flags - for both the selected country, and in the country list
|
||||
showFlags: true,
|
||||
//* Only allow certain chars e.g. a plus followed by numeric digits, and cap at max valid length.
|
||||
strictMode: true,
|
||||
};
|
||||
|
||||
const toString = (val: unknown): string => JSON.stringify(val);
|
||||
|
||||
const isPlainObject = (val: unknown): val is Record<string, unknown> =>
|
||||
Boolean(val) && typeof val === "object" && !Array.isArray(val);
|
||||
|
||||
const isFunction = (val: unknown): val is (...args: unknown[]) => unknown =>
|
||||
typeof val === "function";
|
||||
|
||||
const isElLike = (val: unknown): val is HTMLElement => {
|
||||
if (!val || typeof val !== "object") {
|
||||
return false;
|
||||
}
|
||||
const v = val as any;
|
||||
return (
|
||||
v.nodeType === 1 &&
|
||||
typeof v.tagName === "string" &&
|
||||
typeof v.appendChild === "function"
|
||||
);
|
||||
};
|
||||
|
||||
const placeholderPolicySet = new Set<string>(Object.values(PLACEHOLDER_POLICY));
|
||||
|
||||
const slotSet = new Set<string>(ITI_SLOTS);
|
||||
|
||||
const warn = (message: string): void => {
|
||||
console.warn(`[intl-tel-input] ${message}`);
|
||||
};
|
||||
|
||||
const warnOption = (
|
||||
optionName: string,
|
||||
expectedType: string,
|
||||
actualValue: unknown,
|
||||
): void => {
|
||||
warn(
|
||||
`Option '${optionName}' must be ${expectedType}; got ${toString(actualValue)}. Ignoring.`,
|
||||
);
|
||||
};
|
||||
|
||||
const validateIso2Array = (key: string, value: unknown): string[] | false => {
|
||||
const expectedType = "an array of iso2 country code strings";
|
||||
if (!Array.isArray(value)) {
|
||||
warnOption(key, expectedType, value);
|
||||
return false;
|
||||
}
|
||||
const valid: string[] = [];
|
||||
for (const v of value) {
|
||||
if (typeof v !== "string") {
|
||||
warnOption(key, expectedType, value);
|
||||
return false;
|
||||
}
|
||||
const lower = v.toLowerCase();
|
||||
if (!isIso2(lower)) {
|
||||
warn(`Invalid iso2 code in '${key}': '${v}'. Skipping.`);
|
||||
} else {
|
||||
valid.push(v);
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate runtime init options
|
||||
*/
|
||||
export const validateOptions = (customOptions: unknown): SomeOptions => {
|
||||
if (customOptions === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!isPlainObject(customOptions)) {
|
||||
const error = `The second argument must be an options object; got ${toString(customOptions)}. Using defaults.`;
|
||||
warn(error);
|
||||
return {};
|
||||
}
|
||||
|
||||
const validatedOptions: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(customOptions)) {
|
||||
// Check option exists
|
||||
if (!Object.hasOwn(defaults, key)) {
|
||||
warn(`Unknown option '${key}'. Ignoring.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "allowNumberExtensions":
|
||||
case "allowPhonewords":
|
||||
case "countrySearch":
|
||||
case "dropdownAlwaysOpen":
|
||||
case "matchDropdownWidth":
|
||||
case "formatAsYouType":
|
||||
case "showFlags":
|
||||
case "separateDialCode":
|
||||
case "strictMode":
|
||||
case "strictRejectAnimation":
|
||||
if (typeof value !== "boolean") {
|
||||
warnOption(key, "a boolean", value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "countrySelectorMode":
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!(COUNTRY_SELECTOR_MODES as readonly string[]).includes(value)
|
||||
) {
|
||||
warnOption(
|
||||
"countrySelectorMode",
|
||||
`one of ${COUNTRY_SELECTOR_MODES.map((m) => `"${m}"`).join(", ")}`,
|
||||
value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "numberDisplayFormat":
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value === NUMBER_FORMAT.RFC3966 ||
|
||||
!(value === NUMBER_FORMAT.E164 ||
|
||||
value === NUMBER_FORMAT.INTERNATIONAL ||
|
||||
value === NUMBER_FORMAT.NATIONAL)
|
||||
) {
|
||||
warnOption(
|
||||
"numberDisplayFormat",
|
||||
'one of "E164", "INTERNATIONAL", "NATIONAL"',
|
||||
value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "placeholderNumberPolicy":
|
||||
if (typeof value !== "string" || !placeholderPolicySet.has(value)) {
|
||||
const validPolicies = Array.from(placeholderPolicySet).join(", ");
|
||||
warnOption("placeholderNumberPolicy", `one of ${validPolicies}`, value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "containerClass":
|
||||
case "searchInputClass":
|
||||
case "countryNameLocale":
|
||||
if (typeof value !== "string") {
|
||||
warnOption(key, "a string", value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "classNames": {
|
||||
if (!isPlainObject(value)) {
|
||||
warnOption("classNames", "an object", value);
|
||||
break;
|
||||
}
|
||||
//* Drop unknown slots and non-string values, so a typo is loud rather than silent.
|
||||
const validSlots: Record<string, string> = {};
|
||||
for (const [slot, slotValue] of Object.entries(value)) {
|
||||
if (!slotSet.has(slot)) {
|
||||
warn(
|
||||
`Unknown slot '${slot}' in 'classNames'. Valid slots: ${ITI_SLOTS.join(", ")}. Skipping.`,
|
||||
);
|
||||
} else if (typeof slotValue !== "string") {
|
||||
warnOption(`classNames.${slot}`, "a string", slotValue);
|
||||
} else {
|
||||
//* Normalise whitespace, so consumers can be untidy, and we can safely split on single spaces.
|
||||
validSlots[slot] = slotValue.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
}
|
||||
validatedOptions[key] = validSlots;
|
||||
break;
|
||||
}
|
||||
|
||||
case "countryOrder": {
|
||||
if (value === null) {
|
||||
validatedOptions[key] = value;
|
||||
} else {
|
||||
const filtered = validateIso2Array(key, value);
|
||||
if (filtered !== false) {
|
||||
validatedOptions[key] = filtered;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "customPlaceholder":
|
||||
case "hiddenInputs":
|
||||
case "initialCountryLookup":
|
||||
case "loadUtils":
|
||||
if (value !== null && !isFunction(value)) {
|
||||
warnOption(key, "a function or null", value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "dropdownParent":
|
||||
if (value !== null && !isElLike(value)) {
|
||||
warnOption("dropdownParent", "an HTMLElement or null", value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "excludeCountries":
|
||||
case "onlyCountries": {
|
||||
if (value === null) {
|
||||
validatedOptions[key] = value;
|
||||
} else {
|
||||
const filtered = validateIso2Array(key, value);
|
||||
if (filtered !== false) {
|
||||
validatedOptions[key] = filtered;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "uiTranslations":
|
||||
if (value && !isPlainObject(value)) {
|
||||
warnOption("uiTranslations", "an object", value);
|
||||
break;
|
||||
}
|
||||
// don't bother validating the shape of the object, as the standard use is to just pass in one of the provided locale objects.
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "countryNameOverrides":
|
||||
if (value && !isPlainObject(value)) {
|
||||
warnOption("countryNameOverrides", "an object", value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "initialCountry": {
|
||||
if (typeof value !== "string") {
|
||||
warnOption("initialCountry", "a string", value);
|
||||
break;
|
||||
}
|
||||
const lower = value.toLowerCase();
|
||||
if (lower && !isIso2(lower)) {
|
||||
warnOption("initialCountry", "a valid iso2 country code", value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
}
|
||||
|
||||
case "placeholderNumberType":
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!(NUMBER_TYPES as readonly string[]).includes(value)
|
||||
) {
|
||||
const validTypes = NUMBER_TYPES.join(", ");
|
||||
warnOption("placeholderNumberType", `one of ${validTypes}`, value);
|
||||
break;
|
||||
}
|
||||
validatedOptions[key] = value;
|
||||
break;
|
||||
|
||||
case "allowedNumberTypes":
|
||||
if (value !== null) {
|
||||
if (!Array.isArray(value)) {
|
||||
warnOption(
|
||||
"allowedNumberTypes",
|
||||
"an array of number types or null",
|
||||
value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
let allValid = true;
|
||||
for (const v of value as unknown[]) {
|
||||
if (
|
||||
typeof v !== "string" ||
|
||||
!(NUMBER_TYPES as readonly string[]).includes(v)
|
||||
) {
|
||||
const validTypes = NUMBER_TYPES.join(", ");
|
||||
warnOption(
|
||||
"allowedNumberTypes",
|
||||
`an array of valid number types (${validTypes})`,
|
||||
v,
|
||||
);
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allValid) {
|
||||
// include it (even if empty)
|
||||
validatedOptions[key] = value;
|
||||
}
|
||||
} else {
|
||||
validatedOptions[key] = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return validatedOptions as SomeOptions;
|
||||
};
|
||||
|
||||
// Normalise option values so downstream code doesn't have to (mutates the passed object).
|
||||
export const normaliseOptions = (o: AllOptions): void => {
|
||||
//* Lowercase all iso2 codes so consumers can compare directly.
|
||||
if (o.initialCountry) {
|
||||
o.initialCountry = o.initialCountry.toLowerCase() as Iso2 | "";
|
||||
}
|
||||
if (o.onlyCountries?.length) {
|
||||
o.onlyCountries = o.onlyCountries.map((c) => c.toLowerCase() as Iso2);
|
||||
}
|
||||
if (o.excludeCountries?.length) {
|
||||
o.excludeCountries = o.excludeCountries.map((c) => c.toLowerCase() as Iso2);
|
||||
}
|
||||
if (o.countryOrder) {
|
||||
o.countryOrder = o.countryOrder.map((c) => c.toLowerCase() as Iso2);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply option side-effects (mutates the passed object)
|
||||
export const applyOptionSideEffects = (o: AllOptions): void => {
|
||||
//* Resolve "AUTO" to a concrete mode based on the current viewport. Downstream code only ever sees "OFF", "DROPDOWN", or "FULLSCREEN".
|
||||
if (o.countrySelectorMode === COUNTRY_SELECTOR_MODE.AUTO) {
|
||||
o.countrySelectorMode = resolveAutoCountrySelectorMode();
|
||||
}
|
||||
|
||||
//* dropdownAlwaysOpen requires the dropdown rendering (fullscreen popup doesn't support always-open, and we need the country selector to exist).
|
||||
if (o.dropdownAlwaysOpen) {
|
||||
o.countrySelectorMode = COUNTRY_SELECTOR_MODE.DROPDOWN;
|
||||
}
|
||||
|
||||
//* If showing fullscreen popup, do not fix the width.
|
||||
if (o.countrySelectorMode === COUNTRY_SELECTOR_MODE.FULLSCREEN) {
|
||||
o.matchDropdownWidth = false;
|
||||
} else {
|
||||
// if fullscreen popup disabled for whatever reason, but it's still a narrow screen (so full width dropdown wont fit), then the best UX is to fix dropdown width to input width.
|
||||
if (isNarrowViewport()) {
|
||||
o.matchDropdownWidth = true;
|
||||
}
|
||||
}
|
||||
|
||||
//* If theres only one country, then use it!
|
||||
if (o.onlyCountries?.length === 1) {
|
||||
o.initialCountry = o.onlyCountries[0];
|
||||
}
|
||||
|
||||
//* When separateDialCode enabled, NATIONAL display is contradictory (the dial code is supposed to be thought of as part of the typed number), so force INTERNATIONAL.
|
||||
if (o.separateDialCode && o.numberDisplayFormat === NUMBER_FORMAT.NATIONAL) {
|
||||
o.numberDisplayFormat = NUMBER_FORMAT.INTERNATIONAL;
|
||||
}
|
||||
|
||||
// if there is a country selector, but no flags and no separate dial code, then it suggests that there are multiple countries to choose from, but no way to see which one is currently selected, so we force INTERNATIONAL display, as it doesn't make sense to show a national number placeholder if there's no way to see which country is selected
|
||||
if (
|
||||
o.countrySelectorMode !== COUNTRY_SELECTOR_MODE.OFF &&
|
||||
!o.showFlags &&
|
||||
!o.separateDialCode &&
|
||||
o.numberDisplayFormat === NUMBER_FORMAT.NATIONAL
|
||||
) {
|
||||
o.numberDisplayFormat = NUMBER_FORMAT.INTERNATIONAL;
|
||||
}
|
||||
|
||||
//* Allow overriding the default interface strings.
|
||||
o.uiTranslations = { ...defaultEnglishStrings, ...o.uiTranslations };
|
||||
};
|
||||
1442
libs/intl-tel-input/js/core/ui.ts
Normal file
1442
libs/intl-tel-input/js/core/ui.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user