import type { Country, Iso2 } from "../data.js"; import type { AllOptions, ItiSlot, SelectedCountry } from "../types/public-api.js"; import { buildClassNames, createEl } from "../helpers/dom.js"; import { buildSearchIcon, buildClearIcon, buildCheckIcon, buildGlobeIcon, } from "./icons.js"; import { CLASSES, ARIA, LAYOUT, KEYS, REGEX, TIMINGS, DATA_KEYS, COUNTRY_SELECTOR_MODE, } from "../constants.js"; import { findFirstCountryStartingWith, getMatchedCountries, type SearchTokensMap, } from "./countrySearch.js"; import { Numerals } from "./numerals.js"; //* Feature-detect CSS Anchor Positioning. const supportsCssAnchor = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("anchor-name: --x"); export default class UI { // private readonly #options: AllOptions; readonly #id: number; readonly #isRTL: boolean; readonly #originalPaddingLeft: string = ""; #countries!: Country[]; #searchTokens!: SearchTokensMap; #searchDebounceTimer: ReturnType | null = null; #inlineDropdownHeight?: number; #cssAnchorPositioningDone = false; #countryContainerEl?: HTMLElement; #selectedCountryEl?: HTMLElement; #selectedFlagEl?: HTMLElement; #selectedDialCodeEl?: HTMLElement; #arrowEl?: HTMLElement; #countrySelectorEl?: HTMLElement; #searchIconEl?: HTMLElement; #searchInputEl?: HTMLInputElement; #searchClearButtonEl?: HTMLButtonElement; #countryListEl?: HTMLElement; #hiddenInputPhoneEl?: HTMLInputElement; #hiddenInputCountryEl?: HTMLInputElement; #noResultsMessageEl?: HTMLElement; #searchResultsLiveRegionEl?: HTMLElement; #detachedCountrySelectorEl?: HTMLElement; #selectedListItemEl: HTMLElement | null = null; #highlightedListItemEl: HTMLElement | null = null; readonly #listItemByIso2: Map = new Map(); #countrySelectorAbortController: AbortController | null = null; #resizeObserver?: ResizeObserver; // public public telInputEl!: HTMLInputElement; public readonly hadInitialPlaceholder: boolean; public constructor(input: HTMLInputElement, options: AllOptions, id: number) { input.dataset[DATA_KEYS.INSTANCE_ID] = id.toString(); this.telInputEl = input; this.#options = options; this.#id = id; this.hadInitialPlaceholder = Boolean(input.getAttribute("placeholder")); this.#isRTL = !!this.telInputEl.closest("[dir=rtl]"); //* Store original styling before we override it. this.#originalPaddingLeft = this.telInputEl.style.paddingLeft; } // Validate that the provided element is an HTMLInputElement. public static validateInput(input: unknown): void { const tagName = (input as { tagName?: unknown } | null)?.tagName; const isInputEl = Boolean(input) && typeof input === "object" && tagName === "INPUT" && typeof (input as { setAttribute?: unknown }).setAttribute === "function"; if (!isInputEl) { const type = Object.prototype.toString.call(input); throw new TypeError( `The first argument must be an HTMLInputElement, not ${type}`, ); } } //* Append any consumer-supplied classes (via the classNames option) for the given slot to our own classes for that element. #withSlotClass(slot: ItiSlot, ourClasses: string): string { const custom = this.#options.classNames[slot]; return custom ? `${ourClasses} ${custom}` : ourClasses; } //* Generate all of the markup for the core library: the selected country overlay, and the country selector. public buildMarkup( countries: Country[], searchTokens: SearchTokensMap, ): void { this.#countries = countries; this.#searchTokens = searchTokens; this.telInputEl.classList.add( ...this.#withSlotClass("input", "iti__tel-input").split(" "), ); //* Set useful defaults for phone number input attributes. if (!this.telInputEl.hasAttribute("type")) { this.telInputEl.setAttribute("type", "tel"); } if (!this.telInputEl.hasAttribute("autocomplete")) { this.telInputEl.setAttribute("autocomplete", "tel"); } if (!this.telInputEl.hasAttribute("inputmode")) { this.telInputEl.setAttribute("inputmode", "tel"); } const wrapper = this.#createWrapperAndInsert(); this.#buildCountryContainer(wrapper); wrapper.appendChild(this.telInputEl); this.#updateInputPaddingAndReveal(); this.#observeSelectedCountryResize(); this.#buildHiddenInputs(wrapper); // call this before setInitialState (see commit msg) this.ensureDropdownWidthSet(); } #createWrapperAndInsert(): HTMLElement { const { countrySelectorMode, showFlags, containerClass } = this.#options; //* Containers (mostly for positioning). const parentClasses = buildClassNames({ iti: true, "iti--input-container": true, "iti--has-country-selector": countrySelectorMode !== COUNTRY_SELECTOR_MODE.OFF, "iti--show-flags": showFlags, "iti--inline-country-selector": countrySelectorMode !== COUNTRY_SELECTOR_MODE.FULLSCREEN, [containerClass]: Boolean(containerClass), }); const wrapper = createEl("div", { class: this.#withSlotClass("container", parentClasses), }); // if the page is RTL, then add dir=LTR to the wrapper, as numbers are still written LTR, so the input should be LTR, but we also need to display any separate dial code to the left as well (but we then make the country selector RTL) if (this.#isRTL) { wrapper.setAttribute("dir", "ltr"); } this.telInputEl.before(wrapper); return wrapper; } #buildCountryContainer(wrapper: HTMLElement): void { const { countrySelectorMode, separateDialCode, showFlags } = this.#options; const enableCountrySelector = countrySelectorMode !== COUNTRY_SELECTOR_MODE.OFF; //* If we don't need a countryContainer if (!enableCountrySelector && !showFlags && !separateDialCode) { return; } this.#countryContainerEl = createEl( "div", // visibly hidden until we measure its width to set the input padding correctly { class: this.#withSlotClass( "countryContainer", `iti__country-container ${CLASSES.V_HIDE}`, ), }, wrapper, ); //* Selected country: clickable