mirror of
https://github.com/itflow-org/itflow
synced 2026-09-07 23:35:13 +00:00
1443 lines
55 KiB
TypeScript
1443 lines
55 KiB
TypeScript
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<typeof setTimeout> | 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<Iso2, HTMLElement> = 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 <button> when the country selector is enabled, otherwise a non-interactive <div>. Always shown on the left of the input, even in RTL mode.
|
|
//* https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only
|
|
if (enableCountrySelector) {
|
|
this.#selectedCountryEl = createEl(
|
|
"button",
|
|
{
|
|
type: "button",
|
|
class: this.#withSlotClass("selectedCountry", "iti__selected-country"),
|
|
[ARIA.EXPANDED]: "false",
|
|
[ARIA.LABEL]: this.#options.uiTranslations.noCountrySelected,
|
|
[ARIA.HASPOPUP]: "dialog",
|
|
[ARIA.CONTROLS]: `iti-${this.#id}__country-selector`,
|
|
},
|
|
this.#countryContainerEl,
|
|
);
|
|
|
|
if (this.telInputEl.disabled) {
|
|
this.#selectedCountryEl!.setAttribute("disabled", "true");
|
|
}
|
|
} else {
|
|
this.#selectedCountryEl = createEl(
|
|
"div",
|
|
{ class: this.#withSlotClass("selectedCountry", "iti__selected-country") },
|
|
this.#countryContainerEl,
|
|
);
|
|
}
|
|
|
|
// The element that gets a grey background on hover (if the country selector is enabled)
|
|
const selectedCountryPrimary = createEl(
|
|
"div",
|
|
{
|
|
class: this.#withSlotClass(
|
|
"selectedCountryPrimary",
|
|
"iti__selected-country-primary",
|
|
),
|
|
},
|
|
this.#selectedCountryEl,
|
|
);
|
|
|
|
//* This is where we will add the selected flag (or globe) class later
|
|
this.#selectedFlagEl = createEl(
|
|
"div",
|
|
{ class: this.#withSlotClass("selectedFlag", CLASSES.FLAG) },
|
|
selectedCountryPrimary,
|
|
);
|
|
|
|
if (enableCountrySelector) {
|
|
this.#arrowEl = createEl(
|
|
"div",
|
|
{
|
|
class: this.#withSlotClass("arrow", "iti__arrow"),
|
|
[ARIA.HIDDEN]: "true",
|
|
},
|
|
selectedCountryPrimary,
|
|
);
|
|
}
|
|
|
|
if (separateDialCode) {
|
|
this.#selectedDialCodeEl = createEl(
|
|
"div",
|
|
{
|
|
class: this.#withSlotClass(
|
|
"selectedDialCode",
|
|
"iti__selected-dial-code",
|
|
),
|
|
},
|
|
this.#selectedCountryEl,
|
|
);
|
|
}
|
|
|
|
if (enableCountrySelector) {
|
|
this.#buildCountrySelector();
|
|
}
|
|
}
|
|
|
|
public ensureDropdownWidthSet(): void {
|
|
const { matchDropdownWidth, countrySelectorMode } = this.#options;
|
|
|
|
// Note: matchDropdownWidth is always false if rendering as a fullscreen popup
|
|
// don't re-set it if it's already set
|
|
if (
|
|
countrySelectorMode === COUNTRY_SELECTOR_MODE.OFF ||
|
|
!matchDropdownWidth ||
|
|
this.#countrySelectorEl!.style.width
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const inputWidth = this.telInputEl.offsetWidth;
|
|
// dont fix dropdown width if input width is zero (e.g. it's hidden during init)
|
|
if (inputWidth > 0) {
|
|
this.#countrySelectorEl!.style.width = `${inputWidth}px`;
|
|
}
|
|
}
|
|
|
|
#buildCountrySelector(): void {
|
|
const {
|
|
matchDropdownWidth,
|
|
countrySelectorMode,
|
|
countrySearch,
|
|
uiTranslations,
|
|
containerClass,
|
|
} = this.#options;
|
|
const isFullscreen = countrySelectorMode === COUNTRY_SELECTOR_MODE.FULLSCREEN;
|
|
const detachedParent = this.#getDetachedParent();
|
|
|
|
const extraClasses = matchDropdownWidth ? "" : "iti--flexible-dropdown-width";
|
|
this.#countrySelectorEl = createEl("div", {
|
|
id: `iti-${this.#id}__country-selector`,
|
|
class: this.#withSlotClass(
|
|
"countrySelector",
|
|
`iti__country-selector ${CLASSES.HIDE} ${extraClasses}`,
|
|
),
|
|
role: "dialog",
|
|
[ARIA.MODAL]: "true",
|
|
});
|
|
if (this.#isRTL) {
|
|
this.#countrySelectorEl.setAttribute("dir", "rtl");
|
|
}
|
|
|
|
if (countrySearch) {
|
|
this.#buildSearchUI();
|
|
}
|
|
|
|
this.#countryListEl = createEl(
|
|
"ul",
|
|
{
|
|
class: this.#withSlotClass("countryList", "iti__country-list"),
|
|
id: `iti-${this.#id}__country-listbox`,
|
|
role: "listbox",
|
|
[ARIA.LABEL]: uiTranslations.countryListAriaLabel,
|
|
},
|
|
this.#countrySelectorEl,
|
|
);
|
|
this.#appendListItems();
|
|
|
|
if (countrySearch) {
|
|
this.#updateSearchResultsA11yText();
|
|
}
|
|
|
|
//* NOTE: measuring the inline dropdown size (which forces a synchronous layout reflow) is deferred to the first open — see #ensureInlineDropdownSizeMeasured — so that init does no layout work for a dropdown that may never be opened.
|
|
|
|
//* Detached country selector: required for fullscreen (always attached to document.body), or optional for dropdown (when dropdownParent is set to escape an overflow:hidden ancestor).
|
|
if (detachedParent) {
|
|
const wrapperClasses = buildClassNames({
|
|
iti: true,
|
|
"iti--detached-country-selector": true,
|
|
"iti--fullscreen-popup": isFullscreen,
|
|
"iti--inline-country-selector": !isFullscreen,
|
|
[containerClass]: Boolean(containerClass),
|
|
});
|
|
this.#detachedCountrySelectorEl = createEl("div", {
|
|
class: this.#withSlotClass("countrySelectorContainer", wrapperClasses),
|
|
});
|
|
this.#detachedCountrySelectorEl.appendChild(this.#countrySelectorEl);
|
|
//* NOTE: CSS anchor positioning (which forces a getComputedStyle style recalc) is set up lazily on first open — see #setupCssAnchorPositioning — to keep init free of layout/style work.
|
|
} else {
|
|
this.#countryContainerEl!.appendChild(this.#countrySelectorEl!);
|
|
}
|
|
}
|
|
|
|
//* Resolve the DOM element to attach the country selector to. Fullscreen always uses document.body; dropdown uses the consumer-supplied dropdownParent (if any); otherwise the country selector renders inline within the input wrapper (no detached element).
|
|
#getDetachedParent(): HTMLElement | null {
|
|
const { countrySelectorMode, dropdownParent } = this.#options;
|
|
if (countrySelectorMode === COUNTRY_SELECTOR_MODE.FULLSCREEN) {
|
|
return document.body;
|
|
}
|
|
if (countrySelectorMode === COUNTRY_SELECTOR_MODE.DROPDOWN) {
|
|
return dropdownParent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
#buildSearchUI(): void {
|
|
const { uiTranslations, searchInputClass } = this.#options;
|
|
|
|
// Wrapper so we can position the icons (search + clear)
|
|
const searchWrapper = createEl(
|
|
"div",
|
|
{ class: this.#withSlotClass("searchWrapper", "iti__search-input-wrapper") },
|
|
this.#countrySelectorEl!,
|
|
);
|
|
|
|
// Search (magnifying glass) icon SVG
|
|
this.#searchIconEl = createEl(
|
|
"span",
|
|
{
|
|
class: this.#withSlotClass("searchIcon", "iti__search-icon"),
|
|
[ARIA.HIDDEN]: "true",
|
|
},
|
|
searchWrapper,
|
|
);
|
|
|
|
this.#searchIconEl.appendChild(buildSearchIcon());
|
|
|
|
this.#searchInputEl = createEl(
|
|
"input",
|
|
{
|
|
id: `iti-${this.#id}__search-input`, // Chrome says inputs need either a name or an id
|
|
type: "search",
|
|
class: this.#withSlotClass(
|
|
"searchInput",
|
|
`iti__search-input ${searchInputClass}`,
|
|
),
|
|
placeholder: uiTranslations.searchPlaceholder,
|
|
// role=combobox + aria-autocomplete=list + aria-activedescendant allows maintaining focus on the search input while allowing users to navigate search results with up/down keyboard keys
|
|
role: "combobox",
|
|
[ARIA.EXPANDED]: "true",
|
|
[ARIA.LABEL]: uiTranslations.searchPlaceholder,
|
|
[ARIA.CONTROLS]: `iti-${this.#id}__country-listbox`,
|
|
[ARIA.AUTOCOMPLETE]: "list",
|
|
autocomplete: "off",
|
|
},
|
|
searchWrapper,
|
|
) as HTMLInputElement;
|
|
|
|
this.#searchClearButtonEl = createEl(
|
|
"button",
|
|
{
|
|
type: "button",
|
|
class: this.#withSlotClass(
|
|
"searchClear",
|
|
`iti__search-clear ${CLASSES.HIDE}`,
|
|
),
|
|
[ARIA.LABEL]: uiTranslations.clearSearchAriaLabel,
|
|
tabindex: "-1",
|
|
},
|
|
searchWrapper,
|
|
) as HTMLButtonElement;
|
|
|
|
// Mask creates a transparent cross 'cut' through the filled circle so underlying input bg shows.
|
|
this.#searchClearButtonEl.appendChild(buildClearIcon(this.#id));
|
|
|
|
this.#searchResultsLiveRegionEl = createEl(
|
|
"span",
|
|
{ class: "iti__a11y-text" },
|
|
this.#countrySelectorEl!,
|
|
);
|
|
|
|
// Visible no-results message (hidden by default)
|
|
this.#noResultsMessageEl = createEl(
|
|
"div",
|
|
{
|
|
class: this.#withSlotClass("noResults", `iti__no-results ${CLASSES.HIDE}`),
|
|
[ARIA.HIDDEN]: "true", // all a11y messaging happens in this.#searchResultsLiveRegionEl
|
|
},
|
|
this.#countrySelectorEl!,
|
|
);
|
|
this.#noResultsMessageEl.textContent = uiTranslations.searchEmptyState ?? null;
|
|
}
|
|
|
|
#updateInputPaddingAndReveal(): void {
|
|
if (!this.#countryContainerEl) {
|
|
return;
|
|
}
|
|
this.#updateInputPadding();
|
|
this.#countryContainerEl.classList.remove(CLASSES.V_HIDE);
|
|
}
|
|
|
|
#buildHiddenInputs(wrapper: HTMLElement): void {
|
|
const { hiddenInputs } = this.#options;
|
|
if (!hiddenInputs) {
|
|
return;
|
|
}
|
|
|
|
const telInputName = this.telInputEl.getAttribute("name") || "";
|
|
const names = hiddenInputs(telInputName);
|
|
|
|
if (names.phone) {
|
|
const existingInput = this.telInputEl.form?.querySelector(
|
|
`input[name="${names.phone}"]`,
|
|
);
|
|
if (existingInput) {
|
|
this.#hiddenInputPhoneEl = existingInput as HTMLInputElement;
|
|
} else {
|
|
//* Create hidden input for the full international number.
|
|
this.#hiddenInputPhoneEl = createEl("input", {
|
|
type: "hidden",
|
|
name: names.phone,
|
|
}) as HTMLInputElement;
|
|
wrapper.appendChild(this.#hiddenInputPhoneEl);
|
|
}
|
|
}
|
|
|
|
if (names.country) {
|
|
const existingInput = this.telInputEl.form?.querySelector(
|
|
`input[name="${names.country}"]`,
|
|
);
|
|
if (existingInput) {
|
|
this.#hiddenInputCountryEl = existingInput as HTMLInputElement;
|
|
} else {
|
|
//* Create hidden input for the selected country iso2 code.
|
|
this.#hiddenInputCountryEl = createEl("input", {
|
|
type: "hidden",
|
|
name: names.country,
|
|
}) as HTMLInputElement;
|
|
wrapper.appendChild(this.#hiddenInputCountryEl);
|
|
}
|
|
}
|
|
}
|
|
|
|
//* For each country: add a country list item <li> to the countryList <ul> container.
|
|
#appendListItems(): void {
|
|
const frag = document.createDocumentFragment();
|
|
const liClass = this.#withSlotClass("countryListItem", CLASSES.COUNTRY_ITEM);
|
|
for (let i = 0; i < this.#countries.length; i++) {
|
|
const c = this.#countries[i];
|
|
|
|
const listItem = createEl("li", {
|
|
id: `iti-${this.#id}__item-${c.iso2}`,
|
|
class: liClass,
|
|
tabindex: "-1",
|
|
role: "option",
|
|
[ARIA.SELECTED]: "false",
|
|
});
|
|
listItem.dataset[DATA_KEYS.DIAL_CODE] = c.dialCode;
|
|
listItem.dataset[DATA_KEYS.ISO2] = c.iso2;
|
|
|
|
// Store this for later use e.g. country search filtering.
|
|
this.#listItemByIso2.set(c.iso2, listItem);
|
|
|
|
// Build contents without innerHTML for safety and clarity
|
|
if (this.#options.showFlags) {
|
|
createEl(
|
|
"div",
|
|
{
|
|
class: this.#withSlotClass(
|
|
"countryListItemFlag",
|
|
`${CLASSES.FLAG} iti__${c.iso2}`,
|
|
),
|
|
},
|
|
listItem,
|
|
);
|
|
}
|
|
|
|
const nameEl = createEl(
|
|
"span",
|
|
{ class: this.#withSlotClass("countryName", "iti__country-name") },
|
|
listItem,
|
|
);
|
|
nameEl.textContent = `${c.name} `;
|
|
|
|
// the dial code span sits inside the name span, separated by a space, which works for both LTR and RTL languages
|
|
// (visually it looks better separated by a standard space character, rather than a fixed margin distance, and is more flexible)
|
|
const dialEl = createEl(
|
|
"span",
|
|
{ class: this.#withSlotClass("dialCode", "iti__dial-code") },
|
|
nameEl,
|
|
);
|
|
if (this.#isRTL) {
|
|
dialEl.setAttribute("dir", "ltr");
|
|
}
|
|
dialEl.textContent = `(+${c.dialCode})`;
|
|
|
|
frag.appendChild(listItem);
|
|
}
|
|
this.#countryListEl!.appendChild(frag);
|
|
}
|
|
|
|
//* Update the input padding to make space for (1) the selected country/globe, (2) the arrow, and (3) the separate dial code, all of which are optional, hence handling this in the JS rather than CSS.
|
|
#updateInputPadding(): void {
|
|
if (this.#selectedCountryEl) {
|
|
// fallback widths differ for separateDialCode mode
|
|
const fallbackWidth = this.#options.separateDialCode
|
|
? LAYOUT.FALLBACK_SELECTED_COUNTRY_WITH_DIAL_WIDTH
|
|
: LAYOUT.FALLBACK_SELECTED_COUNTRY_NO_DIAL_WIDTH;
|
|
//* offsetWidth is zero if input is in a hidden container during initialisation.
|
|
const selectedCountryWidth =
|
|
this.#selectedCountryEl.offsetWidth ||
|
|
this.#getHiddenSelectedCountryWidth() ||
|
|
fallbackWidth;
|
|
const inputPadding =
|
|
selectedCountryWidth + LAYOUT.INPUT_PADDING_EXTRA_LEFT;
|
|
this.telInputEl.style.paddingLeft = `${inputPadding}px`;
|
|
}
|
|
}
|
|
|
|
//* Keep the input padding in sync when the selected country's rendered width changes — e.g. responsive font-size shifts that change the dial code text width. Skip while hidden (offsetWidth === 0) so we don't waste work or clobber the padding using a fallback constant.
|
|
#observeSelectedCountryResize(): void {
|
|
if (!this.#selectedCountryEl || typeof ResizeObserver === "undefined") {
|
|
return;
|
|
}
|
|
this.#resizeObserver = new ResizeObserver(() => {
|
|
if (this.#selectedCountryEl?.offsetWidth) {
|
|
this.#updateInputPadding();
|
|
}
|
|
});
|
|
this.#resizeObserver.observe(this.#selectedCountryEl);
|
|
}
|
|
|
|
//* When input is in a hidden container during init, we cannot calculate the selected country width.
|
|
//* Fix: clone the markup, make it invisible, add it to the end of the DOM, and then measure it's width.
|
|
//* To get the right styling to apply, all we need is a shallow clone of the container,
|
|
//* and then to inject a deep clone of the selectedCountryEl element.
|
|
//* Measures in the LOCAL document.body: appending to the local body escapes any hidden ancestor container, and the input's own frame is where intl-tel-input's styles live (so the clone lays out correctly). We deliberately do NOT escape to window.top: that only measures correctly in the rare case where the top frame also loads the library's styles, and measures wrong when it doesn't (e.g. a same-origin iframe whose outer frame lacks the styles — cf. #2178). If the local frame itself isn't laid out yet (e.g. an iframe hidden during init), this returns 0 and the caller falls back to a sane constant; the ResizeObserver in #observeSelectedCountryResize then corrects the padding once the input becomes visible.
|
|
#getHiddenSelectedCountryWidth(): number {
|
|
if (!this.telInputEl.parentNode) {
|
|
return 0;
|
|
}
|
|
|
|
const body = document.body;
|
|
const containerClone = this.telInputEl.parentNode!.cloneNode(
|
|
false,
|
|
) as HTMLElement;
|
|
containerClone.style.visibility = "hidden";
|
|
body.appendChild(containerClone);
|
|
|
|
const countryContainerClone =
|
|
this.#countryContainerEl!.cloneNode() as HTMLElement;
|
|
containerClone.appendChild(countryContainerClone);
|
|
|
|
const selectedCountryClone = this.#selectedCountryEl!.cloneNode(
|
|
true,
|
|
) as HTMLElement;
|
|
countryContainerClone.appendChild(selectedCountryClone);
|
|
|
|
const width = selectedCountryClone.offsetWidth;
|
|
body.removeChild(containerClone);
|
|
return width;
|
|
}
|
|
|
|
//* Measure the inline dropdown size once, lazily, on first open — see #getHiddenInlineDropdownSize for why measuring forces a reflow. Memoised via #inlineDropdownHeight so subsequent opens are free.
|
|
//* Captured for two uses: (1) on open, decide whether to position the dropdown above or below the input; (2) when countrySearch is enabled, pin the dropdown height (and, when matchDropdownWidth is disabled, width) so it doesn't jump around as the country list is filtered.
|
|
#ensureInlineDropdownSizeMeasured(): void {
|
|
if (this.#inlineDropdownHeight !== undefined) {
|
|
return;
|
|
}
|
|
const { countrySearch, matchDropdownWidth } = this.#options;
|
|
const { height, width } = this.#getHiddenInlineDropdownSize();
|
|
this.#inlineDropdownHeight = height;
|
|
// fix the dropdown height when using countrySearch so when dropdown is positioned above input, and you type in the search input and the country list changes, the search input doesn't jump up/down. (NOTE: the country list just has a max-height as it may only be needed to show a few items e.g. from onlyCountries, or from filtering with a search query)
|
|
if (countrySearch) {
|
|
this.#countrySelectorEl!.style.height = `${height}px`;
|
|
// With matchDropdownWidth disabled, the dropdown width tracks its widest country name (white-space: nowrap). Filtering the list would shrink it as the user types, so pin the width to its initial natural width.
|
|
if (!matchDropdownWidth && width > 0) {
|
|
this.#countrySelectorEl!.style.width = `${width}px`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Measure the dropdown by moving it into a temporary hidden container on the body (it needs the right ancestor classes to lay out correctly). Restores it to its original position afterwards — a no-op during init (when it is still detached) but required when called lazily on first open (when it is already inserted).
|
|
//* Deliberately measures in the LOCAL document.body (not window.top): this runs on first open, when the input's own frame is visibly rendered and styled. Escaping to the top frame breaks when the input is inside a same-origin iframe whose outer frame lacks intl-tel-input's styles (e.g. Storybook), as the dropdown would then be measured unstyled and come out far too tall (issue #2178).
|
|
#getHiddenInlineDropdownSize(): { height: number; width: number } {
|
|
const body = document.body;
|
|
const selectorEl = this.#countrySelectorEl!;
|
|
const originalParent = selectorEl.parentNode;
|
|
const originalNextSibling = selectorEl.nextSibling;
|
|
|
|
// safe to remove the hide class as we measure inside a detached, hidden temp container
|
|
selectorEl.classList.remove(CLASSES.HIDE);
|
|
|
|
// it needs these classes on the container to get the correct height
|
|
const tempContainer = createEl("div", {
|
|
class: "iti iti--inline-country-selector",
|
|
});
|
|
tempContainer.appendChild(selectorEl);
|
|
|
|
tempContainer.style.visibility = "hidden";
|
|
body.appendChild(tempContainer);
|
|
const height = selectorEl.offsetHeight;
|
|
const width = selectorEl.offsetWidth;
|
|
body.removeChild(tempContainer);
|
|
|
|
selectorEl.classList.add(CLASSES.HIDE);
|
|
|
|
// restore the dropdown to where it was (no-op at init when originalParent is null)
|
|
if (originalParent) {
|
|
originalParent.insertBefore(selectorEl, originalNextSibling);
|
|
}
|
|
|
|
return {
|
|
height: height > 0 ? height : LAYOUT.FALLBACK_DROPDOWN_HEIGHT,
|
|
width,
|
|
};
|
|
}
|
|
|
|
//* Update search results text (for a11y).
|
|
#updateSearchResultsA11yText(): void {
|
|
const { uiTranslations } = this.#options;
|
|
const count = this.#countryListEl!.childElementCount;
|
|
this.#searchResultsLiveRegionEl!.textContent =
|
|
uiTranslations.searchSummaryAria!(count);
|
|
}
|
|
|
|
//* Country search: Filter the countries according to the search query.
|
|
#filterCountriesByQuery(query: string): void {
|
|
let matchedCountries: Country[];
|
|
|
|
if (query === "") {
|
|
// reset - back to all countries
|
|
matchedCountries = this.#countries;
|
|
} else {
|
|
//* Normalise any Arabic-Indic / Persian digits so dial-code matching works for users typing in alternative numeral sets.
|
|
const normalisedQuery = Numerals.toAscii(query);
|
|
matchedCountries = getMatchedCountries(
|
|
this.#countries,
|
|
this.#searchTokens,
|
|
normalisedQuery,
|
|
);
|
|
}
|
|
this.#showFilteredCountries(matchedCountries);
|
|
}
|
|
|
|
//* Pre-fill the search input with "+" and show all countries
|
|
//* (used when user types "+" in the phone input to open the country selector).
|
|
//* Explicitly focus the search input (openCountrySelector skips this when
|
|
//* dropdownAlwaysOpen, but here we need focus to redirect subsequent keystrokes).
|
|
public prefillSearchWithPlus(): void {
|
|
this.#searchInputEl!.value = "+";
|
|
this.#searchInputEl!.focus();
|
|
this.#filterCountriesByQuery("");
|
|
}
|
|
|
|
// Search input handlers
|
|
#applySearchFilter(): void {
|
|
const inputQuery = this.#searchInputEl!.value.trim();
|
|
this.#filterCountriesByQuery(inputQuery);
|
|
// show/hide clear button
|
|
if (this.#searchInputEl!.value) {
|
|
this.#searchClearButtonEl!.classList.remove(CLASSES.HIDE);
|
|
} else {
|
|
this.#searchClearButtonEl!.classList.add(CLASSES.HIDE);
|
|
}
|
|
}
|
|
|
|
#handleSearchChange(): void {
|
|
// Filtering country nodes is expensive (lots of DOM manipulation), so rate limit it.
|
|
if (this.#searchDebounceTimer) {
|
|
clearTimeout(this.#searchDebounceTimer);
|
|
}
|
|
this.#searchDebounceTimer = setTimeout(() => {
|
|
this.#applySearchFilter();
|
|
this.#searchDebounceTimer = null;
|
|
}, TIMINGS.SEARCH_DEBOUNCE_MS);
|
|
}
|
|
|
|
#handleSearchClear(): void {
|
|
this.#searchInputEl!.value = "";
|
|
this.#searchInputEl!.focus();
|
|
this.#applySearchFilter();
|
|
}
|
|
|
|
//* Check if a country list item element is visible within it's container (the country list), else scroll until it is.
|
|
#scrollCountryListToItem(element: HTMLElement): void {
|
|
const container = this.#countryListEl!;
|
|
const containerRect = container!.getBoundingClientRect();
|
|
const elementRect = element.getBoundingClientRect();
|
|
const offsetTop =
|
|
elementRect.top - containerRect.top + container!.scrollTop;
|
|
|
|
if (elementRect.top < containerRect.top) {
|
|
//* Scroll up.
|
|
container!.scrollTop = offsetTop;
|
|
} else if (elementRect.bottom > containerRect.bottom) {
|
|
//* Scroll down.
|
|
container!.scrollTop =
|
|
offsetTop - containerRect.height + elementRect.height;
|
|
}
|
|
}
|
|
|
|
//* Remove highlighting from the previous list item and highlight the new one.
|
|
#highlightListItem(
|
|
listItem: HTMLElement | null,
|
|
doScroll: boolean = true,
|
|
): void {
|
|
//* Unhighlight the previous item.
|
|
this.#highlightedListItemEl?.classList.remove(CLASSES.HIGHLIGHT);
|
|
|
|
if (listItem) {
|
|
listItem.classList.add(CLASSES.HIGHLIGHT);
|
|
if (this.#options.countrySearch) {
|
|
const activeDescendant = listItem.getAttribute("id") || "";
|
|
this.#searchInputEl!.setAttribute(
|
|
ARIA.ACTIVE_DESCENDANT,
|
|
activeDescendant,
|
|
);
|
|
}
|
|
if (doScroll) {
|
|
this.#scrollCountryListToItem(listItem);
|
|
}
|
|
this.#highlightedListItemEl = listItem;
|
|
} else {
|
|
this.#highlightedListItemEl = null;
|
|
}
|
|
}
|
|
|
|
//* Bind a form-submit listener that syncs the hidden inputs with the current phone number
|
|
//* and country iso2. No-op if there are no hidden inputs or the input is not in a form.
|
|
public bindHiddenInputSubmitListener(
|
|
signal: AbortSignal,
|
|
getPhone: () => string,
|
|
getCountryIso2: () => string,
|
|
): void {
|
|
const form = this.telInputEl.form;
|
|
if (!form || (!this.#hiddenInputPhoneEl && !this.#hiddenInputCountryEl)) {
|
|
return;
|
|
}
|
|
form.addEventListener(
|
|
"submit",
|
|
() => {
|
|
if (this.#hiddenInputPhoneEl) {
|
|
this.#hiddenInputPhoneEl.value = getPhone();
|
|
}
|
|
if (this.#hiddenInputCountryEl) {
|
|
this.#hiddenInputCountryEl.value = getCountryIso2();
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
//* Wire up triggers that open/close the country selector: label click (focus input or swallow repeat click),
|
|
//* selected-country click (open), and keydown on countryContainer (open on arrow/space/enter, close on tab).
|
|
public bindAllInitialCountrySelectorListeners(
|
|
signal: AbortSignal,
|
|
onOpen: () => void,
|
|
onClose: () => void,
|
|
): void {
|
|
//* Hack for input nested inside label (valid markup): clicking the selected country to open the
|
|
//* country selector would otherwise trigger a 2nd click on the input which would close it again.
|
|
const label = this.telInputEl.closest("label");
|
|
if (label) {
|
|
label.addEventListener(
|
|
"click",
|
|
(e: Event): void => {
|
|
//* If the country selector is closed, focus the input; otherwise ignore the click.
|
|
if (!this.isCountrySelectorOpen()) {
|
|
this.telInputEl.focus();
|
|
} else {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
//* Open the country selector on click (unless already open, or input is disabled/readonly).
|
|
this.#selectedCountryEl!.addEventListener(
|
|
"click",
|
|
(): void => {
|
|
if (
|
|
!this.isCountrySelectorOpen() &&
|
|
!this.telInputEl.disabled &&
|
|
!this.telInputEl.readOnly
|
|
) {
|
|
onOpen();
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
//* Open the country selector if selected country is focused and they press up/down/space/enter; close on tab.
|
|
this.#countryContainerEl!.addEventListener(
|
|
"keydown",
|
|
(e: KeyboardEvent): void => {
|
|
const openKeys = [
|
|
KEYS.ARROW_UP,
|
|
KEYS.ARROW_DOWN,
|
|
KEYS.SPACE,
|
|
KEYS.ENTER,
|
|
] as string[];
|
|
|
|
if (!this.isCountrySelectorOpen() && openKeys.includes(e.key)) {
|
|
//* Prevent form submit on ENTER, and prevent document from re-handling this event.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onOpen();
|
|
}
|
|
//* Allow tabbing out of the country selector area.
|
|
if (e.key === KEYS.TAB) {
|
|
onClose();
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
//* Open the country selector: create a fresh AbortController, do the DOM work, and wire up all
|
|
//* open-state listeners (which invoke the caller's onSelect / onClose callbacks).
|
|
public openCountrySelector(
|
|
onSelect: (listItem: HTMLElement | null) => void,
|
|
onClose: () => void,
|
|
): void {
|
|
const { countrySearch, dropdownAlwaysOpen } = this.#options;
|
|
|
|
this.#countrySelectorAbortController = new AbortController();
|
|
|
|
// Lazily measure the inline dropdown size on first open (memoised). Fullscreen doesn't use these measurements. Done before ensureDropdownWidthSet so the natural width/height is measured before matchDropdownWidth pins the width (matching the original init ordering).
|
|
if (this.#options.countrySelectorMode !== COUNTRY_SELECTOR_MODE.FULLSCREEN) {
|
|
this.#ensureInlineDropdownSizeMeasured();
|
|
}
|
|
|
|
// if matchDropdownWidth enabled, and the width was not set during init (e.g. because input was hidden), then set it now as the input must be visible now.
|
|
this.ensureDropdownWidthSet();
|
|
|
|
// Detached country selector: fullscreen popup (always) or detached dropdown (when dropdownParent set).
|
|
if (this.#detachedCountrySelectorEl) {
|
|
this.#injectAndPositionDetachedCountrySelector();
|
|
} else {
|
|
// inline dropdown
|
|
const positionBelow = this.#shouldPositionDropdownBelowInput();
|
|
const distance = this.telInputEl.offsetHeight + LAYOUT.DROPDOWN_MARGIN;
|
|
if (positionBelow) {
|
|
this.#countrySelectorEl!.style.top = `${distance}px`;
|
|
} else {
|
|
this.#countrySelectorEl!.style.bottom = `${distance}px`;
|
|
}
|
|
}
|
|
|
|
this.#countrySelectorEl!.classList.remove(CLASSES.HIDE);
|
|
this.#selectedCountryEl!.setAttribute(ARIA.EXPANDED, "true");
|
|
|
|
//* Highlight the selected country (or fall back to the first item) and scroll it into view.
|
|
const itemToHighlight =
|
|
this.#selectedListItemEl ??
|
|
(this.#countryListEl!.firstElementChild as HTMLElement);
|
|
if (itemToHighlight) {
|
|
this.#highlightListItem(itemToHighlight);
|
|
}
|
|
if (countrySearch && !dropdownAlwaysOpen) {
|
|
this.#searchInputEl!.focus();
|
|
}
|
|
|
|
// When using fullscreen popup, listen for virtual keyboard show/hide via visualViewport
|
|
// so the popup resizes to stay above the keyboard.
|
|
if (
|
|
this.#options.countrySelectorMode === COUNTRY_SELECTOR_MODE.FULLSCREEN &&
|
|
this.#detachedCountrySelectorEl &&
|
|
window.visualViewport
|
|
) {
|
|
window.visualViewport.addEventListener(
|
|
"resize",
|
|
(): void => {
|
|
this.#adjustFullscreenPopupToViewport();
|
|
// Re-scroll to highlighted item after keyboard resize
|
|
if (this.#highlightedListItemEl) {
|
|
this.#scrollCountryListToItem(this.#highlightedListItemEl);
|
|
}
|
|
},
|
|
{ signal: this.#countrySelectorAbortController.signal },
|
|
);
|
|
}
|
|
|
|
// Update the arrow.
|
|
this.#arrowEl!.classList.add(CLASSES.ARROW_UP);
|
|
|
|
this.#bindCountrySelectorOpenListeners(onSelect, onClose);
|
|
}
|
|
|
|
//* Wire up all listeners needed while the country selector is open: list-item hover (highlight),
|
|
//* list-item click & enter key (select), click-off & escape (close), search input (filter),
|
|
//* (when countrySearch disabled) typed-char hidden search, and (when the country selector is in an
|
|
//* external container) update (fixed) position on scroll/resize.
|
|
#bindCountrySelectorOpenListeners(
|
|
onSelect: (listItem: HTMLElement | null) => void,
|
|
onClose: () => void,
|
|
): void {
|
|
const signal = this.#countrySelectorAbortController!.signal;
|
|
this.#bindListItemHover(signal);
|
|
this.#bindListItemClick(signal, onSelect);
|
|
if (!this.#options.dropdownAlwaysOpen) {
|
|
this.#bindOutsideClickToClose(signal, onClose);
|
|
}
|
|
this.#bindCountrySelectorKeydownListener(signal, onSelect, onClose);
|
|
if (this.#options.countrySearch) {
|
|
this.#bindSearchInputListener(signal);
|
|
}
|
|
if (
|
|
this.#options.countrySelectorMode === COUNTRY_SELECTOR_MODE.DROPDOWN &&
|
|
this.#options.dropdownParent &&
|
|
!supportsCssAnchor
|
|
) {
|
|
//* For browsers that support it, we fix the detached dropdown to the input using CSS Anchor Positioning.
|
|
//* For older browsers, we position the dropdown next to the input using fixed coordinates.
|
|
//* Any scroll desyncs the two, so close on scroll. A document-level capture listener catches scroll on any
|
|
//* element (scroll events don't bubble, but the capture phase still fires).
|
|
document.addEventListener("scroll", onClose, { signal, capture: true, passive: true });
|
|
}
|
|
}
|
|
|
|
//* When mouse over a list item, just highlight that one (so if they hit "enter" we know which to select).
|
|
#bindListItemHover(signal: AbortSignal): void {
|
|
this.#countryListEl!.addEventListener(
|
|
"mouseover",
|
|
(e: MouseEvent): void => {
|
|
//* Handle event delegation, as we're listening on the countryList.
|
|
const listItem = (e.target as HTMLElement)?.closest(
|
|
`.${CLASSES.COUNTRY_ITEM}`,
|
|
) as HTMLElement | null;
|
|
if (listItem) {
|
|
this.#highlightListItem(listItem, false);
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
//* Delegate clicks on the country list to the caller's onSelect callback, passing the clicked list item.
|
|
#bindListItemClick(
|
|
signal: AbortSignal,
|
|
onSelect: (listItem: HTMLElement) => void,
|
|
): void {
|
|
this.#countryListEl!.addEventListener(
|
|
"click",
|
|
(e: MouseEvent): void => {
|
|
const listItem = (e.target as HTMLElement)?.closest(
|
|
`.${CLASSES.COUNTRY_ITEM}`,
|
|
) as HTMLElement | null;
|
|
if (listItem) {
|
|
onSelect(listItem);
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
//* Invoke onClickOff when the user clicks anywhere outside the country selector.
|
|
#bindOutsideClickToClose(signal: AbortSignal, onClickOff: () => void): void {
|
|
//* Use setTimeout to bind this listener after the current thread of execution, which is where the opening click is happening (otherwise it would immediately trigger onClickOff so the country selector would never open).
|
|
setTimeout(() => {
|
|
document.documentElement.addEventListener(
|
|
"click",
|
|
(e: MouseEvent): void => {
|
|
if (!this.#countrySelectorEl!.contains(e.target as Node)) {
|
|
onClickOff();
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
//* Keyboard navigation while the country selector is open: arrow keys navigate, hidden-search keys filter,
|
|
//* and enter/escape invoke the caller's callbacks (which handle country selection / close).
|
|
//* Uses keydown rather than keypress so non-char keys (arrow, esc) fire and so holding a key repeats.
|
|
#bindCountrySelectorKeydownListener(
|
|
signal: AbortSignal,
|
|
onEnter: (highlightedListItem: HTMLElement | null) => void,
|
|
onEscape: () => void,
|
|
): void {
|
|
let query = "";
|
|
let queryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const handleKeydown = (e: KeyboardEvent): void => {
|
|
//* Prevent arrow-down from scrolling the whole page, enter from submitting a form, etc.
|
|
const allowedKeys = [
|
|
KEYS.ARROW_UP,
|
|
KEYS.ARROW_DOWN,
|
|
KEYS.ENTER,
|
|
KEYS.ESC,
|
|
] as string[];
|
|
if (allowedKeys.includes(e.key)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (e.key === KEYS.ARROW_UP || e.key === KEYS.ARROW_DOWN) {
|
|
this.#handleUpDownKey(e.key);
|
|
} else if (e.key === KEYS.ENTER && !e.isComposing) {
|
|
//* Enter to select (but not when IME is composing e.g. Japanese input).
|
|
onEnter(this.#highlightedListItemEl);
|
|
} else if (e.key === KEYS.ESC) {
|
|
onEscape();
|
|
//* Accessibility: re-focus the select country button (this is how native <select> elements behave).
|
|
this.#selectedCountryEl!.focus();
|
|
}
|
|
}
|
|
|
|
//* When countrySearch disabled: listen for alpha chars to perform hidden search.
|
|
//* Regex allows one latin alpha char or space, based on https://stackoverflow.com/a/26900132/217866.
|
|
if (
|
|
!this.#options.countrySearch &&
|
|
REGEX.HIDDEN_SEARCH_CHAR.test(e.key)
|
|
) {
|
|
e.stopPropagation();
|
|
if (queryTimer) {
|
|
clearTimeout(queryTimer);
|
|
}
|
|
query += e.key.toLowerCase();
|
|
this.#searchForCountry(query);
|
|
//* Reset the query after a pause so consecutive keystrokes accumulate.
|
|
queryTimer = setTimeout(() => {
|
|
query = "";
|
|
}, TIMINGS.HIDDEN_SEARCH_RESET_MS);
|
|
}
|
|
};
|
|
//* Catches keystrokes while the selected-country button is focused (e.g. countrySearch disabled, or dropdownAlwaysOpen).
|
|
this.#selectedCountryEl?.addEventListener("keydown", handleKeydown, { signal });
|
|
//* Catches keystrokes from the search input and country list (which both live inside the country selector).
|
|
this.#countrySelectorEl?.addEventListener("keydown", handleKeydown, { signal });
|
|
}
|
|
|
|
//* Wire up country search input listener: typing filters the list, the clear button resets it.
|
|
#bindSearchInputListener(signal: AbortSignal): void {
|
|
this.#searchInputEl!.addEventListener(
|
|
"input",
|
|
() => this.#handleSearchChange(),
|
|
{ signal },
|
|
);
|
|
this.#searchClearButtonEl!.addEventListener(
|
|
"click",
|
|
() => this.#handleSearchClear(),
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
//* Hidden search (countrySearch disabled): jump to the first list item whose name starts with the query.
|
|
#searchForCountry(query: string): void {
|
|
const match = findFirstCountryStartingWith(
|
|
this.#countries,
|
|
this.#searchTokens,
|
|
query,
|
|
);
|
|
if (match) {
|
|
const listItem = this.#listItemByIso2.get(match.iso2)!;
|
|
this.#highlightListItem(listItem);
|
|
}
|
|
}
|
|
|
|
//* Highlight the next/prev item in the list (and ensure it is visible).
|
|
#handleUpDownKey(key: string): void {
|
|
let next =
|
|
key === KEYS.ARROW_UP
|
|
? (this.#highlightedListItemEl?.previousElementSibling as HTMLElement)
|
|
: (this.#highlightedListItemEl?.nextElementSibling as HTMLElement);
|
|
if (!next && this.#countryListEl!.childElementCount > 1) {
|
|
//* Otherwise, we must be at the end, so loop round again.
|
|
next =
|
|
key === KEYS.ARROW_UP
|
|
? (this.#countryListEl!.lastElementChild as HTMLElement)
|
|
: (this.#countryListEl!.firstElementChild as HTMLElement);
|
|
}
|
|
if (next) {
|
|
//* Make sure the next item is visible
|
|
this.#highlightListItem(next);
|
|
}
|
|
}
|
|
|
|
// Update the selected list item in the country list
|
|
#updateSelectedListItem(iso2: Iso2 | ""): void {
|
|
// if the existing selected item is different to the new country, set aria-selected to false
|
|
if (
|
|
this.#selectedListItemEl &&
|
|
this.#selectedListItemEl.dataset[DATA_KEYS.ISO2] !== iso2
|
|
) {
|
|
this.#selectedListItemEl.setAttribute(ARIA.SELECTED, "false");
|
|
this.#selectedListItemEl.querySelector(".iti__country-check")?.remove();
|
|
this.#selectedListItemEl = null;
|
|
}
|
|
|
|
// if setting to a new country (rather than null/globe icon, or the existing selected item), find the new list item and set aria-selected to true
|
|
if (iso2 && !this.#selectedListItemEl) {
|
|
const newListItem = this.#countryListEl!.querySelector(
|
|
`[data-iso2="${iso2}"]`,
|
|
) as HTMLElement;
|
|
if (newListItem) {
|
|
newListItem.setAttribute(ARIA.SELECTED, "true");
|
|
const checkIcon = createEl(
|
|
"span",
|
|
{
|
|
class: this.#withSlotClass("countryCheck", "iti__country-check"),
|
|
[ARIA.HIDDEN]: "true",
|
|
},
|
|
newListItem,
|
|
);
|
|
checkIcon.appendChild(buildCheckIcon());
|
|
this.#selectedListItemEl = newListItem;
|
|
//* With dropdownAlwaysOpen, the country selector is visible throughout, so keep the highlighted
|
|
//* row in sync with the selection (e.g. when initialCountryLookup resolves, or the user types a
|
|
//* different dial code) — otherwise it keeps pointing at a stale row.
|
|
if (this.#options.dropdownAlwaysOpen) {
|
|
this.#highlightListItem(newListItem);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//* Country search: Filter the country list to the given array of countries.
|
|
#showFilteredCountries(matchedCountries: Country[]): void {
|
|
// remove all items from the list
|
|
this.#countryListEl!.replaceChildren();
|
|
|
|
let noCountriesAddedYet = true;
|
|
for (const c of matchedCountries) {
|
|
const listItem = this.#listItemByIso2.get(c.iso2);
|
|
if (listItem) {
|
|
this.#countryListEl!.appendChild(listItem);
|
|
|
|
//* Highlight the first item
|
|
if (noCountriesAddedYet) {
|
|
this.#highlightListItem(listItem, false);
|
|
noCountriesAddedYet = false;
|
|
}
|
|
}
|
|
}
|
|
if (noCountriesAddedYet) {
|
|
//* If no countries are shown, unhighlight the previously highlighted item.
|
|
this.#highlightListItem(null);
|
|
if (this.#noResultsMessageEl) {
|
|
this.#noResultsMessageEl.classList.remove(CLASSES.HIDE);
|
|
}
|
|
} else if (this.#noResultsMessageEl) {
|
|
this.#noResultsMessageEl.classList.add(CLASSES.HIDE);
|
|
}
|
|
//* Scroll to top (useful if user had previously scrolled down).
|
|
this.#countryListEl!.scrollTop = 0;
|
|
this.#updateSearchResultsA11yText();
|
|
}
|
|
|
|
// UI: Close the country selector (DOM + abort scoped listeners).
|
|
public closeCountrySelector(): void {
|
|
const { countrySearch } = this.#options;
|
|
|
|
//* Unbind all country-selector-scoped listeners in one go.
|
|
this.#countrySelectorAbortController!.abort();
|
|
this.#countrySelectorAbortController = null;
|
|
|
|
this.#countrySelectorEl!.classList.add(CLASSES.HIDE);
|
|
this.#selectedCountryEl!.setAttribute(ARIA.EXPANDED, "false");
|
|
|
|
if (countrySearch) {
|
|
this.#searchInputEl!.removeAttribute(ARIA.ACTIVE_DESCENDANT);
|
|
// Clear the search query so it starts fresh next time.
|
|
this.#searchInputEl!.value = "";
|
|
this.#applySearchFilter();
|
|
// only clear the highlighted item if countrySearch is enabled as this gets reset each time the country selector is opened
|
|
if (this.#highlightedListItemEl) {
|
|
this.#highlightedListItemEl.classList.remove(CLASSES.HIGHLIGHT);
|
|
this.#highlightedListItemEl = null;
|
|
}
|
|
}
|
|
|
|
// Update the arrow.
|
|
this.#arrowEl!.classList.remove(CLASSES.ARROW_UP);
|
|
|
|
// Remove country selector from container if using detached element
|
|
if (this.#detachedCountrySelectorEl) {
|
|
this.#detachedCountrySelectorEl.remove();
|
|
this.#detachedCountrySelectorEl.style.top = "";
|
|
this.#detachedCountrySelectorEl.style.bottom = "";
|
|
this.#detachedCountrySelectorEl.style.paddingLeft = "";
|
|
this.#detachedCountrySelectorEl.style.paddingRight = "";
|
|
} else {
|
|
this.#countrySelectorEl!.style.top = "";
|
|
this.#countrySelectorEl!.style.bottom = "";
|
|
}
|
|
}
|
|
|
|
#shouldPositionDropdownBelowInput(): boolean {
|
|
// for testing, it's helpful for it to always be shown below.
|
|
if (this.#options.dropdownAlwaysOpen) {
|
|
return true;
|
|
}
|
|
const inputPos = this.telInputEl.getBoundingClientRect();
|
|
const spaceAbove = inputPos.top;
|
|
const spaceBelow = window.innerHeight - inputPos.bottom;
|
|
return (
|
|
spaceBelow >= this.#inlineDropdownHeight! || spaceBelow >= spaceAbove
|
|
);
|
|
}
|
|
|
|
// inject the country selector into its detached wrapper and apply positioning styles
|
|
#injectAndPositionDetachedCountrySelector(): void {
|
|
const isFullscreen = this.#options.countrySelectorMode === COUNTRY_SELECTOR_MODE.FULLSCREEN;
|
|
const detachedParent = this.#getDetachedParent();
|
|
|
|
if (isFullscreen) {
|
|
// on wider screens, constrain the popup to the input width instead of full width
|
|
if (window.innerWidth >= LAYOUT.NARROW_VIEWPORT_WIDTH) {
|
|
const inputPos = this.telInputEl.getBoundingClientRect();
|
|
this.#detachedCountrySelectorEl!.style.paddingLeft = `${inputPos.left}px`;
|
|
this.#detachedCountrySelectorEl!.style.paddingRight = `${window.innerWidth - inputPos.right}px`;
|
|
}
|
|
} else {
|
|
// Set up CSS anchor positioning on first open (memoised) before the dropdown is revealed. Inert in browsers without anchor() support, which use the fixed-coordinate fallback below instead.
|
|
this.#setupCssAnchorPositioning();
|
|
}
|
|
|
|
if (!isFullscreen && !supportsCssAnchor) {
|
|
//* For browsers that support it, we fix the detached dropdown to the input using CSS Anchor Positioning.
|
|
//* For older browsers, we position the dropdown next to the input using fixed coordinates.
|
|
const inputPos = this.telInputEl.getBoundingClientRect();
|
|
this.#detachedCountrySelectorEl!.style.left = `${inputPos.left}px`;
|
|
if (this.#shouldPositionDropdownBelowInput()) {
|
|
this.#detachedCountrySelectorEl!.style.top = `${inputPos.bottom + LAYOUT.DROPDOWN_MARGIN}px`;
|
|
} else {
|
|
// unset the default top:-1000px in the CSS
|
|
this.#detachedCountrySelectorEl!.style.top = "unset";
|
|
this.#detachedCountrySelectorEl!.style.bottom = `${window.innerHeight - inputPos.top + LAYOUT.DROPDOWN_MARGIN}px`;
|
|
}
|
|
}
|
|
|
|
detachedParent!.appendChild(this.#detachedCountrySelectorEl!);
|
|
}
|
|
|
|
//* Wire up CSS Anchor Positioning between the input and the detached country selector using a
|
|
//* unique anchor name per instance. Called lazily on first open (memoised) — the matching styles in
|
|
//* intlTelInput.css only take effect in browsers that support anchor(); elsewhere these
|
|
//* properties are inert. We append our name to any existing anchor-name (read via
|
|
//* getComputedStyle so we pick up CSS-defined values), so consumer-set anchors on the input
|
|
//* are preserved. Caveat: this snapshots the consumer's value once — if they later change
|
|
//* anchor-name via CSS (e.g. a class swap), our inline write will shadow the change.
|
|
#setupCssAnchorPositioning(): void {
|
|
if (this.#cssAnchorPositioningDone) {
|
|
return;
|
|
}
|
|
this.#cssAnchorPositioningDone = true;
|
|
const anchorName = `--iti-anchor-${this.#id}`;
|
|
const existing = getComputedStyle(this.telInputEl).anchorName;
|
|
this.telInputEl.style.anchorName =
|
|
existing && existing !== "none" ? `${existing}, ${anchorName}` : anchorName;
|
|
this.#detachedCountrySelectorEl!.style.positionAnchor = anchorName;
|
|
}
|
|
|
|
// Adjust the fullscreen popup dimensions to match the visual viewport,
|
|
// so it stays above the virtual keyboard on mobile devices.
|
|
#adjustFullscreenPopupToViewport(): void {
|
|
const vv = window.visualViewport;
|
|
if (!vv || !this.#detachedCountrySelectorEl) {
|
|
return;
|
|
}
|
|
const virtualKeyboardHeight = window.innerHeight - vv.height;
|
|
this.#detachedCountrySelectorEl.style.bottom = `${virtualKeyboardHeight}px`;
|
|
}
|
|
|
|
// UI: Whether the country selector is currently open (visible).
|
|
public isCountrySelectorOpen(): boolean {
|
|
return !this.#countrySelectorEl!.classList.contains(CLASSES.HIDE);
|
|
}
|
|
|
|
// Toggle the loading spinner on the selected flag (used during auto-country geoIP lookup).
|
|
public setLoading(isLoading: boolean): void {
|
|
this.#selectedFlagEl!.classList.toggle(CLASSES.LOADING, isLoading);
|
|
}
|
|
|
|
//* Play the strict-reject animation (shake, or background-colour flash under prefers-reduced-motion) on the wrapper.
|
|
//* Called when strictMode rejects the whole input (keystroke, or whole paste).
|
|
//* Uses the wrapper (not the input) so any separateDialCode / country button move together with the input.
|
|
public playStrictRejectAnimation(): void {
|
|
if (!this.#options.strictRejectAnimation) {
|
|
return;
|
|
}
|
|
const wrapperEl = this.telInputEl.parentElement;
|
|
if (!wrapperEl) {
|
|
return;
|
|
}
|
|
wrapperEl.classList.remove(CLASSES.STRICT_REJECT_ANIMATION);
|
|
//* Force reflow so re-adding the class restarts the animation even if it's already running.
|
|
void wrapperEl.offsetWidth;
|
|
wrapperEl.classList.add(CLASSES.STRICT_REJECT_ANIMATION);
|
|
//* Remove the class once the animation finishes, otherwise hiding then re-showing the container
|
|
//* (e.g. a <dialog>) replays the animation because the class is still present.
|
|
wrapperEl.addEventListener(
|
|
"animationend",
|
|
() => wrapperEl.classList.remove(CLASSES.STRICT_REJECT_ANIMATION),
|
|
{ once: true },
|
|
);
|
|
}
|
|
|
|
public isLoading(): boolean {
|
|
return this.#selectedFlagEl!.classList.contains(CLASSES.LOADING);
|
|
}
|
|
|
|
// Set the disabled state of the input and country selector.
|
|
public setDisabled(disabled: boolean): void {
|
|
this.telInputEl.disabled = disabled;
|
|
if (this.#selectedCountryEl) {
|
|
if (disabled) {
|
|
// selectedCountryEl can be a button or a div, which doesn't support the disabled property, so we use the attribute
|
|
this.#selectedCountryEl.setAttribute("disabled", "true");
|
|
} else {
|
|
this.#selectedCountryEl.removeAttribute("disabled");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set the readonly state of the input and country selector.
|
|
public setReadonly(readonly: boolean): void {
|
|
this.telInputEl.readOnly = readonly;
|
|
if (this.#selectedCountryEl) {
|
|
if (readonly) {
|
|
// readonly doesn't have any effect on the selected country button/div, so we use disabled instead
|
|
// selectedCountryEl can be a button or a div, which doesn't support the disabled property, so we use the attribute
|
|
this.#selectedCountryEl.setAttribute("disabled", "true");
|
|
} else {
|
|
this.#selectedCountryEl.removeAttribute("disabled");
|
|
}
|
|
}
|
|
}
|
|
|
|
public setSelectedCountry(selectedCountry: SelectedCountry): void {
|
|
const { countrySelectorMode, showFlags, separateDialCode, uiTranslations } = this.#options;
|
|
const name = selectedCountry?.name;
|
|
const dialCode = selectedCountry?.dialCode;
|
|
const iso2 = selectedCountry?.iso2 ?? "";
|
|
|
|
if (countrySelectorMode !== COUNTRY_SELECTOR_MODE.OFF) {
|
|
// Update the selected list item in the country list
|
|
this.#updateSelectedListItem(iso2);
|
|
}
|
|
|
|
//* Update the selected flag class and the a11y text.
|
|
if (this.#selectedCountryEl) {
|
|
//* Note: this whole className gets overwritten below, so re-apply the consumer's classes for this slot.
|
|
const flagClass = this.#withSlotClass(
|
|
"selectedFlag",
|
|
iso2 && showFlags
|
|
? `${CLASSES.FLAG} iti__${iso2}`
|
|
: `${CLASSES.FLAG} ${CLASSES.GLOBE}`,
|
|
);
|
|
let ariaLabel, title;
|
|
let flagContent: SVGElement | null = null;
|
|
if (iso2) {
|
|
title = name;
|
|
ariaLabel = uiTranslations
|
|
.selectedCountryAriaLabel!.replace("${countryName}", name!)
|
|
.replace("${dialCode}", `+${dialCode}`);
|
|
if (!showFlags) {
|
|
flagContent = buildGlobeIcon();
|
|
}
|
|
} else {
|
|
title = uiTranslations.noCountrySelected;
|
|
ariaLabel = uiTranslations.noCountrySelected;
|
|
flagContent = buildGlobeIcon();
|
|
}
|
|
// Note: if auto country loading state is still active at this point, the loading class gets wiped here, which is appropriate as a country has been selected.
|
|
this.#selectedFlagEl!.className = flagClass;
|
|
this.#selectedCountryEl!.setAttribute("title", title!);
|
|
this.#selectedCountryEl!.setAttribute(ARIA.LABEL, ariaLabel!);
|
|
if (flagContent) {
|
|
this.#selectedFlagEl!.replaceChildren(flagContent);
|
|
} else {
|
|
this.#selectedFlagEl!.replaceChildren();
|
|
}
|
|
}
|
|
|
|
//* Update the selected dial code.
|
|
if (separateDialCode) {
|
|
const fullDialCode = dialCode ? `+${dialCode}` : "";
|
|
this.#selectedDialCodeEl!.textContent = fullDialCode;
|
|
this.#updateInputPadding();
|
|
}
|
|
}
|
|
|
|
public destroy(): void {
|
|
//* Break cross-references from long-lived objects back to this instance.
|
|
this.telInputEl.iti = undefined;
|
|
delete this.telInputEl.dataset[DATA_KEYS.INSTANCE_ID];
|
|
|
|
this.#resizeObserver?.disconnect();
|
|
|
|
//* Restore original styling
|
|
this.telInputEl.style.paddingLeft = this.#originalPaddingLeft;
|
|
|
|
//* Remove markup (but leave the original input). parentNode may be null if the host framework (e.g. Svelte) detached the input before destroy() ran; the orphaned wrapper has no parent and will be GC'd once references are released.
|
|
const wrapper = this.telInputEl.parentNode as HTMLElement | null;
|
|
if (wrapper) {
|
|
wrapper.before(this.telInputEl);
|
|
wrapper.remove();
|
|
}
|
|
|
|
this.#listItemByIso2.clear();
|
|
}
|
|
}
|