feat(forms): add useMaskedField and useMaskedInput composables for input masking

This commit is contained in:
2026-06-09 13:54:52 +07:00
parent 6de7c72fb3
commit 07937e26db
426 changed files with 12981 additions and 311 deletions
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { assignStyle, getTranslate, isInView, resetStyle, setStyle } from './index';
function makeEl(): HTMLElement {
const el = document.createElement('div');
document.body.append(el);
return el;
}
describe('setStyle / resetStyle', () => {
it('applies styles and caches the overwritten values', () => {
const el = makeEl();
el.style.transform = 'translateY(0px)';
setStyle(el, { transform: 'translateY(20px)', transition: 'none' });
expect(el.style.transform).toBe('translateY(20px)');
expect(el.style.transition).toBe('none');
resetStyle(el);
expect(el.style.transform).toBe('translateY(0px)');
});
it('restores a single property when given prop', () => {
const el = makeEl();
el.style.opacity = '1';
setStyle(el, { opacity: '0', transform: 'scale(0.9)' });
resetStyle(el, 'opacity');
expect(el.style.opacity).toBe('1');
expect(el.style.transform).toBe('scale(0.9)');
});
it('writes custom properties via setProperty', () => {
const el = makeEl();
setStyle(el, { '--snap-point-height': '120px' });
expect(el.style.getPropertyValue('--snap-point-height')).toBe('120px');
});
it('does not cache when ignoreCache is true', () => {
const el = makeEl();
el.style.opacity = '1';
setStyle(el, { opacity: '0.5' }, true);
resetStyle(el);
expect(el.style.opacity).toBe('0.5');
});
it('is a no-op for non-elements', () => {
expect(() => setStyle(null, { opacity: '0' })).not.toThrow();
expect(() => resetStyle(null)).not.toThrow();
});
});
describe('getTranslate', () => {
it('returns null when there is no matrix transform', () => {
const el = makeEl();
expect(getTranslate(el, 'y')).toBeNull();
});
it('reads x and y from a 2D matrix', () => {
const el = makeEl();
el.style.transform = 'matrix(1, 0, 0, 1, 12, 34)';
expect(getTranslate(el, 'x')).toBe(12);
expect(getTranslate(el, 'y')).toBe(34);
});
it('reads x and y from a 3D matrix', () => {
const el = makeEl();
el.style.transform = 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 60, 0, 1)';
expect(getTranslate(el, 'x')).toBe(50);
expect(getTranslate(el, 'y')).toBe(60);
});
});
describe('assignStyle', () => {
it('assigns styles and restores the previous cssText on cleanup', () => {
const el = makeEl();
el.style.cssText = 'color: red;';
const restore = assignStyle(el, { overflow: 'hidden' });
expect(el.style.overflow).toBe('hidden');
restore();
expect(el.style.overflow).toBe('');
expect(el.style.color).toBe('red');
});
it('returns a no-op cleanup for a missing element', () => {
expect(() => assignStyle(null, { overflow: 'hidden' })()).not.toThrow();
});
});
describe('isInView', () => {
it('returns false when visualViewport is unavailable', () => {
const el = makeEl();
const original = window.visualViewport;
Object.defineProperty(globalThis, 'visualViewport', { value: null, configurable: true });
expect(isInView(el)).toBe(false);
Object.defineProperty(globalThis, 'visualViewport', { value: original, configurable: true });
});
});
@@ -0,0 +1,190 @@
/**
* A patch of inline styles — a map of CSS property names to string values.
* Keys may be camelCase DOM style properties (`borderRadius`) or `--custom`
* properties (set verbatim via `setProperty`).
*/
export type StylePatch = Record<string, string>;
/**
* The axis a translation is read along: `x` (horizontal) or `y` (vertical).
*/
export type TranslateAxis = 'x' | 'y';
// Remembers the styles that {@link setStyle} overwrote, keyed by element, so
// {@link resetStyle} can put them back. A WeakMap lets the entry be collected
// once the element is gone.
const originalStyles = new WeakMap<HTMLElement, StylePatch>();
/**
* @name setStyle
* @category Browsers
* @description Applies a batch of inline styles to an element, remembering the
* values it overwrote so {@link resetStyle} can restore them later. `--custom`
* properties are written through `setProperty`. Pass `ignoreCache` to apply the
* styles without recording the originals (e.g. for transient, per-frame writes
* during a drag that you intend to clear wholesale).
*
* @param {Element | HTMLElement | null} [element] The element to style (ignored if not an `HTMLElement`)
* @param {StylePatch} [styles] The property/value pairs to apply
* @param {boolean} [ignoreCache] Skip remembering the overwritten values
* @returns {void}
*
* @example
* setStyle(el, { transition: 'none', transform: 'translateY(20px)' });
* setStyle(el, { opacity: '0.5' }, true); // transient — won't be restored
*
* @since 0.0.5
*/
export function setStyle(element?: Element | HTMLElement | null, styles?: StylePatch, ignoreCache = false): void {
if (!element || !(element instanceof HTMLElement) || !styles)
return;
const previous: StylePatch = {};
for (const [key, value] of Object.entries(styles)) {
if (key.startsWith('--')) {
element.style.setProperty(key, value);
continue;
}
previous[key] = (element.style as unknown as StylePatch)[key];
(element.style as unknown as StylePatch)[key] = value;
}
if (ignoreCache)
return;
originalStyles.set(element, previous);
}
/**
* @name resetStyle
* @category Browsers
* @description Restores the inline styles an element had before the most recent
* cached {@link setStyle}. With `prop` it restores a single property; otherwise
* it restores every property that was remembered. A no-op if nothing was cached.
*
* @param {Element | HTMLElement | null} element The element to restore
* @param {string} [prop] Restore only this property instead of all of them
* @returns {void}
*
* @example
* resetStyle(el); // restore everything setStyle changed
* resetStyle(el, 'transform'); // restore just the transform
*
* @since 0.0.5
*/
export function resetStyle(element: Element | HTMLElement | null, prop?: string): void {
if (!element || !(element instanceof HTMLElement))
return;
const previous = originalStyles.get(element);
if (!previous)
return;
if (prop) {
(element.style as unknown as StylePatch)[prop] = previous[prop];
return;
}
for (const [key, value] of Object.entries(previous))
(element.style as unknown as StylePatch)[key] = value;
}
/**
* @name getTranslate
* @category Browsers
* @description Reads the current translation of an element along one axis from
* its computed `transform`, parsing both `matrix(...)` (2D) and `matrix3d(...)`
* (3D) forms. Returns `null` when the element has no matrix transform.
*
* @param {HTMLElement} element The element to measure
* @param {TranslateAxis} axis `'x'` for horizontal, `'y'` for vertical
* @returns {number | null} The translation in pixels, or `null` if none
*
* @example
* const offset = getTranslate(panel, 'y'); // px the panel is shifted down
*
* @since 0.0.5
*/
export function getTranslate(element: HTMLElement, axis: TranslateAxis): number | null {
const style = globalThis.getComputedStyle(element);
const transform
// @ts-expect-error — vendor-prefixed transforms only exist in some browsers
= style.transform || style.webkitTransform || style.mozTransform;
let match = transform.match(/^matrix3d\((.+)\)$/);
if (match) {
// matrix3d: the translate components live at indices 12 (x) and 13 (y).
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d
return Number.parseFloat(match[1].split(', ')[axis === 'y' ? 13 : 12]);
}
// matrix: the translate components live at indices 4 (x) and 5 (y).
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
match = transform.match(/^matrix\((.+)\)$/);
return match ? Number.parseFloat(match[1].split(', ')[axis === 'y' ? 5 : 4]) : null;
}
/**
* @name assignStyle
* @category Browsers
* @description Merges a style patch onto an element's inline `style` and returns
* a cleanup function that restores the element's entire previous `cssText`.
* Unlike {@link setStyle}, the snapshot is the full `cssText`, so the cleanup is
* an all-or-nothing revert — handy for scoped effects.
*
* @param {HTMLElement | null | undefined} element The element to style
* @param {Partial<CSSStyleDeclaration>} style The styles to assign
* @returns {() => void} A cleanup function that restores the previous `cssText`
*
* @example
* const restore = assignStyle(document.body, { overflow: 'hidden' });
* // ...later
* restore();
*
* @since 0.0.5
*/
export function assignStyle(element: HTMLElement | null | undefined, style: Partial<CSSStyleDeclaration>): () => void {
if (!element)
return () => {};
const previousCssText = element.style.cssText;
Object.assign(element.style, style);
return () => {
element.style.cssText = previousCssText;
};
}
/**
* @name isInView
* @category Browsers
* @description Reports whether an element is fully within the visual viewport,
* accounting for on-screen keyboards via `window.visualViewport`. A 40px slack
* is allowed at the bottom to tolerate Safari's viewport quirks. Returns `false`
* when `visualViewport` is unavailable.
*
* @param {HTMLElement} element The element to test
* @returns {boolean} `true` if the element's rect fits inside the visual viewport
*
* @example
* if (!isInView(focusedField)) scrollIntoView(focusedField);
*
* @since 0.0.5
*/
export function isInView(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
if (!window.visualViewport)
return false;
return (
rect.top >= 0
&& rect.left >= 0
// +40 of slack for Safari's visual-viewport reporting.
&& rect.bottom <= window.visualViewport.height - 40
&& rect.right <= window.visualViewport.width
);
}
+3
View File
@@ -1,4 +1,7 @@
export * from './animationLifecycle';
export * from './domStyle';
export * from './focusGuard';
export * from './focusScope';
export * from './hideOthers';
export * from './inputState';
export * from './userAgent';
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { readInputState, writeInputState } from './index';
function makeInput(value = ''): HTMLInputElement {
const input = document.createElement('input');
input.value = value;
document.body.append(input);
return input;
}
describe('readInputState', () => {
it('reads value and selection', () => {
const input = makeInput('hello');
input.setSelectionRange(1, 3);
expect(readInputState(input)).toEqual({ value: 'hello', selection: [1, 3] });
});
it('falls back to a collapsed caret at the end when selection is null', () => {
const input = makeInput('abc');
// Force a null selection (some input types report null).
Object.defineProperty(input, 'selectionStart', { value: null });
Object.defineProperty(input, 'selectionEnd', { value: null });
expect(readInputState(input)).toEqual({ value: 'abc', selection: [3, 3] });
});
});
describe('writeInputState', () => {
it('writes the value', () => {
const input = makeInput('a');
writeInputState(input, { value: '(12)', selection: [4, 4] });
expect(input.value).toBe('(12)');
});
it('moves the caret only while the element is focused', () => {
const input = makeInput('12345');
input.focus();
writeInputState(input, { value: '12345', selection: [2, 4] });
expect(input.selectionStart).toBe(2);
expect(input.selectionEnd).toBe(4);
});
it('does not reposition the caret when not focused', () => {
const input = makeInput('12345');
input.setSelectionRange(0, 0);
input.blur();
writeInputState(input, { value: '12345', selection: [3, 3] });
// Unfocused: selection is left untouched by setSelectionRange.
expect(input.selectionStart).toBe(0);
});
});
@@ -0,0 +1,80 @@
/**
* The editable state of a text field: its current text and selection bounds.
* Framework-agnostic and structurally compatible with any `{ value, selection }`
* pair (e.g. an input-masking engine's element state).
*/
export interface InputState {
/**
* The element's current `value`.
*/
readonly value: string;
/**
* The selection as `[from, to]` (collapsed caret when `from === to`).
*/
readonly selection: readonly [from: number, to: number];
}
/**
* A text field whose value and selection can be read and written.
*/
export type TextFieldElement = HTMLInputElement | HTMLTextAreaElement;
/**
* @name readInputState
* @category Browsers
* @description Reads the value and current selection of an `<input>`/`<textarea>`
* into a plain {@link InputState}. A `null` selection (some input types report it)
* falls back to a collapsed caret at the end of the value.
*
* @param {TextFieldElement} element The input or textarea to read
* @returns {InputState} The element's `{ value, selection }`
*
* @example
* const { value, selection } = readInputState(inputEl);
*
* @since 0.0.5
*/
export function readInputState(element: TextFieldElement): InputState {
const { value, selectionStart, selectionEnd } = element;
const end = value.length;
return {
value,
selection: [selectionStart ?? end, selectionEnd ?? end],
};
}
/**
* @name writeInputState
* @category Browsers
* @description Writes value and selection back to an `<input>`/`<textarea>`. The
* value is only assigned when it actually changed (avoids spurious cursor jumps),
* and the caret is moved **only while the element is focused** so programmatic
* updates never steal or reposition focus. `setSelectionRange` is guarded because
* some input types (`number`, `email`, `date`) forbid it.
*
* @param {TextFieldElement} element The input or textarea to update
* @param {InputState} state The `{ value, selection }` to apply
* @returns {void}
*
* @example
* writeInputState(inputEl, { value: '(123)', selection: [5, 5] });
*
* @since 0.0.5
*/
export function writeInputState(element: TextFieldElement, state: InputState): void {
const [from, to] = state.selection;
if (element.value !== state.value)
element.value = state.value;
if (element.ownerDocument.activeElement !== element)
return;
try {
element.setSelectionRange(from, to);
}
catch {
// Input types like number/email/date throw on setSelectionRange — ignore.
}
}
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isMac, isMobileFirefox, isSafari, testUserAgentPlatform } from './index';
function stubPlatform(platform: string): void {
vi.spyOn(globalThis.navigator, 'platform', 'get').mockReturnValue(platform);
}
function stubUserAgent(ua: string): void {
vi.spyOn(globalThis.navigator, 'userAgent', 'get').mockReturnValue(ua);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe('testUserAgentPlatform', () => {
it('matches against navigator.platform', () => {
stubPlatform('MacIntel');
expect(testUserAgentPlatform(/^Mac/)).toBe(true);
expect(testUserAgentPlatform(/^Win/)).toBe(false);
});
});
describe('isMac', () => {
it('is true on a Mac platform', () => {
stubPlatform('MacIntel');
expect(isMac()).toBe(true);
});
it('is false elsewhere', () => {
stubPlatform('Win32');
expect(isMac()).toBe(false);
});
});
describe('isSafari', () => {
it('is true for a Safari UA', () => {
stubUserAgent('Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15');
expect(isSafari()).toBe(true);
});
it('is false for Chrome', () => {
stubUserAgent('Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36');
expect(isSafari()).toBe(false);
});
});
describe('isMobileFirefox', () => {
it('is true for Android Firefox', () => {
stubUserAgent('Mozilla/5.0 (Android 14; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0');
expect(isMobileFirefox()).toBe(true);
});
it('is true for iOS Firefox (FxiOS)', () => {
stubUserAgent('Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 FxiOS/121.0 Mobile/15E148 Safari/605.1.15');
expect(isMobileFirefox()).toBe(true);
});
it('is false for desktop Firefox', () => {
stubUserAgent('Mozilla/5.0 (Windows NT 10.0; rv:121.0) Gecko/20100101 Firefox/121.0');
expect(isMobileFirefox()).toBe(false);
});
});
@@ -0,0 +1,117 @@
/**
* @name testUserAgentPlatform
* @category Browsers
* @description Tests `navigator.platform` against a regular expression, guarding
* for non-browser environments. Returns `undefined` when there is no `navigator`
* (e.g. during SSR) so callers can distinguish "no" from "unknown".
*
* @param {RegExp} re The pattern to test `navigator.platform` against
* @returns {boolean | undefined} The match result, or `undefined` outside a browser
*
* @example
* const onMac = testUserAgentPlatform(/^Mac/);
*
* @since 0.0.5
*/
export function testUserAgentPlatform(re: RegExp): boolean | undefined {
return globalThis.navigator !== undefined
? re.test(globalThis.navigator.platform)
: undefined;
}
/**
* @name isMac
* @category Browsers
* @description Whether the current platform is macOS (per `navigator.platform`).
* Note iPadOS reports as a Mac — combine with {@link isIPad} to disambiguate.
*
* @returns {boolean | undefined} `true` on macOS, `undefined` outside a browser
*
* @since 0.0.5
*/
export function isMac(): boolean | undefined {
return testUserAgentPlatform(/^Mac/);
}
/**
* @name isIPhone
* @category Browsers
* @description Whether the current platform is an iPhone (per `navigator.platform`).
*
* @returns {boolean | undefined} `true` on iPhone, `undefined` outside a browser
*
* @since 0.0.5
*/
export function isIPhone(): boolean | undefined {
return testUserAgentPlatform(/^iPhone/);
}
/**
* @name isIPad
* @category Browsers
* @description Whether the current device is an iPad. iPadOS 13+ masquerades as a
* Mac, so this also treats a touch-capable Mac (`maxTouchPoints > 1`) as an iPad.
*
* @returns {boolean | undefined} `true` on iPad, `undefined` outside a browser
*
* @since 0.0.5
*/
export function isIPad(): boolean | undefined {
return (
testUserAgentPlatform(/^iPad/)
// iPadOS 13+ lies and reports as a Mac; touch support gives it away.
|| (isMac() && navigator.maxTouchPoints > 1)
);
}
/**
* @name isIOS
* @category Browsers
* @description Whether the current device runs iOS/iPadOS (iPhone or iPad).
*
* @returns {boolean | undefined} `true` on iOS, `undefined` outside a browser
*
* @since 0.0.5
*/
export function isIOS(): boolean | undefined {
return isIPhone() || isIPad();
}
/**
* @name isSafari
* @category Browsers
* @description Whether the current browser is Safari (desktop or iOS), excluding
* Chrome and Android browsers that also include "Safari" in their UA string.
*
* @returns {boolean} `true` if the user agent looks like Safari
*
* @since 0.0.5
*/
export function isSafari(): boolean {
if (typeof navigator === 'undefined')
return false;
// eslint-disable-next-line regexp/no-unused-capturing-group
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}
/**
* @name isMobileFirefox
* @category Browsers
* @description Whether the current browser is Firefox on a mobile device
* (Android Firefox or iOS Firefox / `FxiOS`).
*
* @returns {boolean} `true` on mobile Firefox
*
* @since 0.0.5
*/
export function isMobileFirefox(): boolean {
if (typeof navigator === 'undefined')
return false;
const userAgent = navigator.userAgent;
return (
(/Firefox/.test(userAgent) && /Mobile/.test(userAgent)) // Android Firefox
|| /FxiOS/.test(userAgent) // iOS Firefox
);
}
@@ -0,0 +1,51 @@
/**
* Payment-card brands: detection patterns (IIN/BIN prefixes and ranges), the
* number template (`#` = a digit), valid total lengths, and the security-code
* info. Derived from the MIT-licensed `credit-card-type` dataset (Braintree).
* The template uses the brand's gap positions; the trailing group is sized to the
* longest valid length, so shorter numbers simply leave it partly empty.
*/
/**
* A card brand's prefix pattern: an exact prefix string, or an inclusive numeric
* range `[min, max]` whose bounds have the same digit length.
*/
export type CardPattern = string | readonly [min: number, max: number];
/**
* One payment-card brand.
*/
export interface CardBrand {
/** Brand id, e.g. `'visa'`. */
readonly brand: string;
/** Display name, e.g. `'Visa'`. */
readonly name: string;
/** Number template; `#` is a digit, e.g. `'#### #### #### ####'`. */
readonly template: string;
/** Valid total digit lengths, e.g. `[16, 18, 19]`. */
readonly lengths: readonly number[];
/** Security code metadata, e.g. `{ name: 'CVV', size: 3 }`. */
readonly code: { readonly name: string; readonly size: number };
/** IIN/BIN detection patterns (prefixes and ranges). */
readonly patterns: readonly CardPattern[];
}
/**
* Every supported card brand. Resolve a number with `findCardBrand`.
*/
export const CARD_BRANDS: readonly CardBrand[] = [
{ brand: 'visa', name: 'Visa', template: '#### #### #### #######', lengths: [16, 18, 19], code: { name: 'CVV', size: 3 }, patterns: ['4'] },
{ brand: 'mastercard', name: 'Mastercard', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: [[51, 55], [2221, 2229], [223, 229], [23, 26], [270, 271], '2720'] },
{ brand: 'american-express', name: 'American Express', template: '#### ###### #####', lengths: [15], code: { name: 'CID', size: 4 }, patterns: ['34', '37'] },
{ brand: 'diners-club', name: 'Diners Club', template: '#### ###### #########', lengths: [14, 16, 19], code: { name: 'CVV', size: 3 }, patterns: [[300, 305], '36', '38', '39'] },
{ brand: 'discover', name: 'Discover', template: '#### #### #### #######', lengths: [16, 19], code: { name: 'CID', size: 3 }, patterns: ['6011', [644, 649], '65'] },
{ brand: 'jcb', name: 'JCB', template: '#### #### #### #######', lengths: [16, 17, 18, 19], code: { name: 'CVV', size: 3 }, patterns: ['2131', '1800', [3528, 3589]] },
{ brand: 'unionpay', name: 'UnionPay', template: '#### #### #### #######', lengths: [14, 15, 16, 17, 18, 19], code: { name: 'CVN', size: 3 }, patterns: ['620', [62100, 62182], [62184, 62187], [62185, 62197], [62200, 62205], [622010, 622999], '622018', [62207, 62209], [623, 626], '6270', '6272', '6276', [627700, 627779], [627781, 627799], [6282, 6289], '6291', '6292', '810', [8110, 8131], [8132, 8151], [8152, 8163], [8164, 8171]] },
{ brand: 'maestro', name: 'Maestro', template: '#### #### #### #######', lengths: [12, 13, 14, 15, 16, 17, 18, 19], code: { name: 'CVC', size: 3 }, patterns: ['493698', [500000, 504174], [504176, 506698], [506779, 508999], [56, 59], '63', '67', '6'] },
{ brand: 'elo', name: 'Elo', template: '#### #### #### ####', lengths: [16], code: { name: 'CVE', size: 3 }, patterns: ['401178', '401179', '438935', '457631', '457632', '431274', '451416', '457393', '504175', [506699, 506778], [509000, 509999], '627780', '636297', '636368', [650031, 650033], [650035, 650051], [650405, 650439], [650485, 650538], [650541, 650598], [650700, 650718], [650720, 650727], [650901, 650978], [651652, 651679], [655000, 655019], [655021, 655058]] },
{ brand: 'mir', name: 'Mir', template: '#### #### #### #######', lengths: [16, 17, 18, 19], code: { name: 'CVP2', size: 3 }, patterns: [[2200, 2204]] },
{ brand: 'hiper', name: 'Hiper', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: ['637095', '63737423', '63743358', '637568', '637599', '637609', '637612'] },
{ brand: 'hipercard', name: 'Hipercard', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: ['606282'] },
{ brand: 'naranja', name: 'Naranja', template: '#### #### #### ####', lengths: [16], code: { name: 'CVV', size: 3 }, patterns: ['589562', '402918', '527572'] },
{ brand: 'verve', name: 'Verve', template: '#### #### #### #######', lengths: [16, 18, 19], code: { name: 'CVV', size: 3 }, patterns: [[506099, 506127], '506129', [506133, 506150], [506158, 506163], '506166', '506168', '506170', '506173', [506176, 506180], '506184', [506187, 506188], '506191', '506195', '506197', '507865', '507866', [507868, 507877], [507880, 507888], '507900', '507941'] },
];
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { CARD_BRANDS } from './card-brands';
import { findCardBrand } from './find-card-brand';
describe(findCardBrand, () => {
it('detects major brands by IIN', () => {
expect(findCardBrand('4111111111111111')?.brand).toBe('visa');
expect(findCardBrand('5500005555555559')?.brand).toBe('mastercard');
expect(findCardBrand('2221001234567890')?.brand).toBe('mastercard'); // 2-series
expect(findCardBrand('371449635398431')?.brand).toBe('american-express');
expect(findCardBrand('30569309025904')?.brand).toBe('diners-club');
expect(findCardBrand('6011000990139424')?.brand).toBe('discover');
expect(findCardBrand('2200123412341234')?.brand).toBe('mir');
});
it('narrows down as digits are typed', () => {
expect(findCardBrand('4')?.brand).toBe('visa');
});
it('exposes a template, lengths and security code for every brand', () => {
expect(CARD_BRANDS.length).toBeGreaterThan(5);
for (const brand of CARD_BRANDS) {
expect(brand.template).toMatch(/#/);
expect(brand.lengths.length).toBeGreaterThan(0);
expect(brand.code.size).toBeGreaterThan(0);
}
});
it('returns undefined for empty or unknown input', () => {
expect(findCardBrand('')).toBeUndefined();
expect(findCardBrand('0000')).toBeUndefined();
});
});
@@ -0,0 +1,85 @@
import { CARD_BRANDS } from './card-brands';
import type { CardBrand, CardPattern } from './card-brands';
interface RangeMeta {
readonly width: number;
readonly minString: string;
readonly maxString: string;
}
// Precompute the constant string forms of every range pattern once, so the
// per-keystroke scan doesn't re-derive them via String() on each comparison.
const RANGE_META = new Map<CardPattern, RangeMeta>();
for (const brand of CARD_BRANDS) {
for (const pattern of brand.patterns) {
if (typeof pattern !== 'string') {
const maxString = String(pattern[1]);
RANGE_META.set(pattern, { width: maxString.length, minString: String(pattern[0]), maxString });
}
}
}
/**
* Score how specifically `digits` matches a pattern: the number of leading digits
* that match (higher = more specific), or `0` for no match. Supports partial
* input as the user types.
*/
function patternScore(digits: string, pattern: CardPattern): number {
if (typeof pattern === 'string') {
const length = Math.min(pattern.length, digits.length);
return digits.slice(0, length) === pattern.slice(0, length) ? length : 0;
}
const meta = RANGE_META.get(pattern);
const maxString = meta ? meta.maxString : String(pattern[1]);
const minString = meta ? meta.minString : String(pattern[0]);
const width = meta ? meta.width : maxString.length;
const prefix = digits.slice(0, width);
const value = Number(prefix);
const low = Number(minString.slice(0, prefix.length));
const high = Number(maxString.slice(0, prefix.length));
return value >= low && value <= high ? prefix.length : 0;
}
/**
* @name findCardBrand
* @category Multi
* @description Detect a payment-card brand from a number's digits by its IIN/BIN
* pattern. Returns the brand whose pattern matches the most leading digits (so it
* narrows down as the user types), or `undefined` if none match. Pure.
*
* @param {string} digits The card number's digits (no separators)
* @param {readonly CardBrand[]} [brands=CARD_BRANDS] The brand list
* @returns {CardBrand | undefined} The detected brand, or `undefined`
*
* @example
* findCardBrand('4111111111111111')?.brand; // 'visa'
* findCardBrand('371449635398431')?.brand; // 'american-express'
* findCardBrand('2200123412341234')?.brand; // 'mir'
*
* @since 0.0.5
*/
export function findCardBrand(
digits: string,
brands: readonly CardBrand[] = CARD_BRANDS,
): CardBrand | undefined {
if (!digits)
return undefined;
let best: CardBrand | undefined;
let bestScore = 0;
for (const brand of brands) {
for (const pattern of brand.patterns) {
const score = patternScore(digits, pattern);
if (score > bestScore) {
best = brand;
bestScore = score;
}
}
}
return best;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './card-brands';
export * from './find-card-brand';
export * from './validate';
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { luhn } from '@robonen/encoding';
import { isValidCardNumber } from './validate';
describe(isValidCardNumber, () => {
it('requires a valid checksum and a brand-matching length', () => {
expect(isValidCardNumber('4111 1111 1111 1111')).toBe(true); // visa, 16
expect(isValidCardNumber('371449635398431')).toBe(true); // amex, 15
});
it('rejects a Luhn-valid number outside the valid length range', () => {
expect(luhn('79927398713')).toBe(true); // Luhn-valid…
expect(isValidCardNumber('79927398713')).toBe(false); // …but only 11 digits
});
it('rejects a bad checksum', () => {
expect(isValidCardNumber('4111 1111 1111 1112')).toBe(false);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { luhn } from '@robonen/encoding';
import { CARD_BRANDS } from './card-brands';
import type { CardBrand } from './card-brands';
import { findCardBrand } from './find-card-brand';
const NON_DIGIT = /\D/g;
/**
* @name isValidCardNumber
* @category Multi
* @description Whether `value` is a complete, valid payment-card number: it passes
* the Luhn checksum (`luhn` from `@robonen/encoding`) AND its digit length matches
* the detected {@link findCardBrand} brand (or the 1219 digit ISO/IEC 7812 range
* when the brand is unknown). For the bare checksum, use `luhn` directly.
*
* @param {string} value The card number (separators allowed)
* @param {readonly CardBrand[]} [brands=CARD_BRANDS] The brand list
* @returns {boolean} Whether the number is structurally valid
*
* @example
* isValidCardNumber('4111 1111 1111 1111'); // true
* isValidCardNumber('4111 1111 1111'); // false (wrong length)
*
* @since 0.0.5
*/
export function isValidCardNumber(value: string, brands: readonly CardBrand[] = CARD_BRANDS): boolean {
if (!luhn(value))
return false;
const digits = value.replaceAll(NON_DIGIT, '');
const brand = findCardBrand(digits, brands);
return brand
? brand.lengths.includes(digits.length)
: digits.length >= 12 && digits.length <= 19;
}
+2
View File
@@ -1 +1,3 @@
export * from './card';
export * from './global';
export * from './intl';
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { findPhoneCountry } from './find-phone-country';
import { PHONE_COUNTRIES } from './phone-countries';
describe(findPhoneCountry, () => {
it('every dialing code is prefix-free', () => {
const codes = PHONE_COUNTRIES.map(country => country.code);
expect(codes.some(a => codes.some(b => a !== b && b.startsWith(a)))).toBe(false);
});
it('returns the primary country for a shared code when no area code matches', () => {
expect(findPhoneCountry('12025550123')?.iso2).toBe('us');
expect(findPhoneCountry('74951234567')?.iso2).toBe('ru');
});
it('disambiguates a shared code by area code', () => {
expect(findPhoneCountry('14165550123')?.iso2).toBe('ca'); // +1 416 → Canada
expect(findPhoneCountry('12425550123')?.iso2).toBe('bs'); // +1 242 → Bahamas
expect(findPhoneCountry('73101234567')?.iso2).toBe('kz'); // +7 310 → Kazakhstan
});
it('matches a unique long code', () => {
expect(findPhoneCountry('380441234567')?.iso2).toBe('ua');
});
it('returns undefined for empty or unknown input', () => {
expect(findPhoneCountry('')).toBeUndefined();
expect(findPhoneCountry('000')).toBeUndefined();
});
});
@@ -0,0 +1,109 @@
import { PHONE_COUNTRIES } from './phone-countries';
import type { PhoneCountry } from './phone-countries';
function buildPhoneIndex(countries: readonly PhoneCountry[]): Map<string, PhoneCountry[]> {
const index = new Map<string, PhoneCountry[]>();
for (const country of countries) {
let bucket = index.get(country.code);
if (!bucket) {
bucket = [];
index.set(country.code, bucket);
}
bucket.push(country);
}
return index;
}
// O(1) lookup over the default dataset, keyed by dialing code. Codes are
// prefix-free (E.164), so probing the first 3..1 digits finds the unique code —
// far cheaper than scanning all ~211 countries on every keystroke.
const PHONE_INDEX = buildPhoneIndex(PHONE_COUNTRIES);
/**
* Among countries sharing a dialing code, pick the most specific matching area
* code, else the lowest priority (the primary country). `rest` is the digits
* after the dialing code.
*/
function resolveGroup(group: readonly PhoneCountry[], rest: string): PhoneCountry | undefined {
const first = group[0];
if (!first)
return undefined;
if (group.length === 1)
return first;
let best: PhoneCountry | undefined;
let bestAreaLength = 0;
for (const country of group) {
for (const area of country.areaCodes ?? []) {
if (area.length > bestAreaLength && rest.startsWith(area)) {
best = country;
bestAreaLength = area.length;
}
}
}
if (best)
return best;
return group.reduce((winner, country) =>
(winner.priority ?? 0) <= (country.priority ?? 0) ? winner : country);
}
/**
* @name findPhoneCountry
* @category Multi
* @description Resolve a digit string to its country among a {@link PhoneCountry}
* list. Matches the **longest dialing code** (codes are prefix-free, so this is
* unambiguous), then — for countries sharing a code (NANP `+1`, `+7` RU/KZ) — the
* most specific **area code**, then the lowest **priority** (the primary country)
* when no area code matches. The default dataset is indexed for O(1) lookup; a
* custom list falls back to a linear scan.
*
* @param {string} digits The number's digits (no `+`/separators), e.g. `'14165550123'`
* @param {readonly PhoneCountry[]} [countries=PHONE_COUNTRIES] The country list
* @returns {PhoneCountry | undefined} The matched country, or `undefined`
*
* @example
* findPhoneCountry('12025550123')?.iso2; // 'us'
* findPhoneCountry('14165550123')?.iso2; // 'ca' (area code 416)
* findPhoneCountry('12425550123')?.iso2; // 'bs' (area code 242)
*
* @since 0.0.5
*/
export function findPhoneCountry(
digits: string,
countries: readonly PhoneCountry[] = PHONE_COUNTRIES,
): PhoneCountry | undefined {
if (!digits)
return undefined;
// Fast path: the default dataset is indexed by dialing code.
if (countries === PHONE_COUNTRIES) {
for (let length = Math.min(3, digits.length); length >= 1; length--) {
const bucket = PHONE_INDEX.get(digits.slice(0, length));
if (bucket)
return resolveGroup(bucket, digits.slice(length));
}
return undefined;
}
// Fallback (custom list): longest dialing-code prefix via a linear scan.
let codeLength = -1;
const group: PhoneCountry[] = [];
for (const country of countries) {
if (!digits.startsWith(country.code))
continue;
if (country.code.length > codeLength) {
codeLength = country.code.length;
group.length = 0;
group.push(country);
}
else if (country.code.length === codeLength) {
group.push(country);
}
}
return codeLength === -1 ? undefined : resolveGroup(group, digits.slice(codeLength));
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { getCountryFlagByCode } from './flag';
describe(getCountryFlagByCode, () => {
it('maps alpha-2 codes to flag emoji', () => {
expect(getCountryFlagByCode('US')).toBe('🇺🇸');
expect(getCountryFlagByCode('UA')).toBe('🇺🇦');
expect(getCountryFlagByCode('GB')).toBe('🇬🇧');
});
it('is case-insensitive', () => {
expect(getCountryFlagByCode('gb')).toBe(getCountryFlagByCode('GB'));
});
it('returns an empty string for invalid input', () => {
expect(getCountryFlagByCode('1')).toBe('');
expect(getCountryFlagByCode('USA')).toBe('');
expect(getCountryFlagByCode('u')).toBe('');
expect(getCountryFlagByCode('')).toBe('');
});
});
+34
View File
@@ -0,0 +1,34 @@
// Offset from an ASCII uppercase letter to its Unicode regional indicator
// symbol: 'A' (U+0041) → 🇦 (U+1F1E6).
const REGIONAL_INDICATOR_OFFSET = 0x1F1E6 - 'A'.charCodeAt(0);
const ALPHA2 = /^[a-z]{2}$/i;
/**
* @name getCountryFlagByCode
* @category Multi
* @description Convert an ISO 3166-1 alpha-2 country code (e.g. `'RU'`, `'us'`)
* into its flag emoji by mapping each letter to a Unicode regional indicator
* symbol. Case-insensitive; returns an empty string for anything that isn't two
* ASCII letters. Pure and environment-agnostic.
*
* @param {string} code The ISO 3166-1 alpha-2 code, e.g. `'US'`
* @returns {string} The flag emoji (e.g. `'🇺🇸'`), or `''` when the code is invalid
*
* @example
* getCountryFlagByCode('RU'); // '🇷🇺'
* getCountryFlagByCode('us'); // '🇺🇸'
* getCountryFlagByCode('123'); // ''
*
* @since 0.0.5
*/
export function getCountryFlagByCode(code: string): string {
if (!ALPHA2.test(code))
return '';
const upper = code.toUpperCase();
return String.fromCodePoint(
REGIONAL_INDICATOR_OFFSET + upper.charCodeAt(0),
REGIONAL_INDICATOR_OFFSET + upper.charCodeAt(1),
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './flag';
export * from './find-phone-country';
export * from './phone-countries';
@@ -0,0 +1,247 @@
/**
* Phone-number formats for every country: an international template with the
* dialing-code digits as `#` digit placeholders, plus the `priority`/`areaCodes`
* used to disambiguate countries sharing a dialing code (NANP `+1`, `+7` RU/KZ).
* NANP territories are normalized to code `'1'` (their 3-digit prefix moved into
* `areaCodes`), so every dialing code is prefix-free and the `+1` block shares one
* numbering-plan template. Derived from the MIT-licensed `react-phone-input-2`
* dataset — display formats, not authoritative validation.
*/
/**
* One country's phone-number format.
*/
export interface PhoneCountry {
/** Display name, e.g. `'United States'` (omit for custom lists). */
readonly name?: string;
/** ISO 3166-1 alpha-2 code, lowercase, e.g. `'us'` (omit for custom lists). */
readonly iso2?: string;
/** International dialing code digits (no `+`), e.g. `'1'`, `'380'`. */
readonly code: string;
/** International template; `#` is a digit, e.g. `'+# (###) ###-####'`. */
readonly template: string;
/**
* Order among countries sharing `code` — lower wins when no area code matches.
* Absent means `0` (the primary country for that code).
*/
readonly priority?: number;
/** Area codes (the digits right after `code`) that belong to this country. */
readonly areaCodes?: readonly string[];
}
/**
* Every supported country. Resolve a number with `findPhoneCountry`.
*/
export const PHONE_COUNTRIES: readonly PhoneCountry[] = [
{ name: 'Afghanistan', iso2: 'af', code: '93', template: '+## #############' },
{ name: 'Albania', iso2: 'al', code: '355', template: '+### ############' },
{ name: 'Algeria', iso2: 'dz', code: '213', template: '+### ############' },
{ name: 'Andorra', iso2: 'ad', code: '376', template: '+### ############' },
{ name: 'Angola', iso2: 'ao', code: '244', template: '+### ############' },
{ name: 'Antigua and Barbuda', iso2: 'ag', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['268'] },
{ name: 'Argentina', iso2: 'ar', code: '54', template: '+## (##) ########' },
{ name: 'Armenia', iso2: 'am', code: '374', template: '+### ## ######' },
{ name: 'Aruba', iso2: 'aw', code: '297', template: '+### ############' },
{ name: 'Australia', iso2: 'au', code: '61', template: '+## (##) #### ####' },
{ name: 'Austria', iso2: 'at', code: '43', template: '+## #############' },
{ name: 'Azerbaijan', iso2: 'az', code: '994', template: '+### (##) ### ## ##' },
{ name: 'Bahamas', iso2: 'bs', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['242'] },
{ name: 'Bahrain', iso2: 'bh', code: '973', template: '+### ############' },
{ name: 'Bangladesh', iso2: 'bd', code: '880', template: '+### ############' },
{ name: 'Barbados', iso2: 'bb', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['246'] },
{ name: 'Belarus', iso2: 'by', code: '375', template: '+### (##) ### ## ##' },
{ name: 'Belgium', iso2: 'be', code: '32', template: '+## ### ## ## ##' },
{ name: 'Belize', iso2: 'bz', code: '501', template: '+### ############' },
{ name: 'Benin', iso2: 'bj', code: '229', template: '+### ############' },
{ name: 'Bhutan', iso2: 'bt', code: '975', template: '+### ############' },
{ name: 'Bolivia', iso2: 'bo', code: '591', template: '+### ############' },
{ name: 'Bosnia and Herzegovina', iso2: 'ba', code: '387', template: '+### ############' },
{ name: 'Botswana', iso2: 'bw', code: '267', template: '+### ############' },
{ name: 'Brazil', iso2: 'br', code: '55', template: '+## (##) #########' },
{ name: 'British Indian Ocean Territory', iso2: 'io', code: '246', template: '+### ############' },
{ name: 'Brunei', iso2: 'bn', code: '673', template: '+### ############' },
{ name: 'Bulgaria', iso2: 'bg', code: '359', template: '+### ############' },
{ name: 'Burkina Faso', iso2: 'bf', code: '226', template: '+### ############' },
{ name: 'Burundi', iso2: 'bi', code: '257', template: '+### ############' },
{ name: 'Cambodia', iso2: 'kh', code: '855', template: '+### ############' },
{ name: 'Cameroon', iso2: 'cm', code: '237', template: '+### ############' },
{ name: 'Canada', iso2: 'ca', code: '1', template: '+# (###) ###-####', priority: 1, areaCodes: ['204', '226', '236', '249', '250', '289', '306', '343', '365', '387', '403', '416', '418', '431', '437', '438', '450', '506', '514', '519', '548', '579', '581', '587', '604', '613', '639', '647', '672', '705', '709', '742', '778', '780', '782', '807', '819', '825', '867', '873', '902', '905'] },
{ name: 'Cape Verde', iso2: 'cv', code: '238', template: '+### ############' },
{ name: 'Caribbean Netherlands', iso2: 'bq', code: '599', template: '+### ############', priority: 1 },
{ name: 'Central African Republic', iso2: 'cf', code: '236', template: '+### ############' },
{ name: 'Chad', iso2: 'td', code: '235', template: '+### ############' },
{ name: 'Chile', iso2: 'cl', code: '56', template: '+## #############' },
{ name: 'China', iso2: 'cn', code: '86', template: '+## ##-#########' },
{ name: 'Colombia', iso2: 'co', code: '57', template: '+## ### ### ####' },
{ name: 'Comoros', iso2: 'km', code: '269', template: '+### ############' },
{ name: 'Congo', iso2: 'cd', code: '243', template: '+### ############' },
{ name: 'Congo', iso2: 'cg', code: '242', template: '+### ############' },
{ name: 'Costa Rica', iso2: 'cr', code: '506', template: '+### ####-####' },
{ name: 'Côte dIvoire', iso2: 'ci', code: '225', template: '+### ## ## ## ##' },
{ name: 'Croatia', iso2: 'hr', code: '385', template: '+### ############' },
{ name: 'Cuba', iso2: 'cu', code: '53', template: '+## #############' },
{ name: 'Curaçao', iso2: 'cw', code: '599', template: '+### ############' },
{ name: 'Cyprus', iso2: 'cy', code: '357', template: '+### ## ######' },
{ name: 'Czech Republic', iso2: 'cz', code: '420', template: '+### ### ### ###' },
{ name: 'Denmark', iso2: 'dk', code: '45', template: '+## ## ## ## ##' },
{ name: 'Djibouti', iso2: 'dj', code: '253', template: '+### ############' },
{ name: 'Dominica', iso2: 'dm', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['767'] },
{ name: 'Dominican Republic', iso2: 'do', code: '1', template: '+# (###) ###-####', priority: 2, areaCodes: ['809', '829', '849'] },
{ name: 'Ecuador', iso2: 'ec', code: '593', template: '+### ############' },
{ name: 'Egypt', iso2: 'eg', code: '20', template: '+## #############' },
{ name: 'El Salvador', iso2: 'sv', code: '503', template: '+### ####-####' },
{ name: 'Equatorial Guinea', iso2: 'gq', code: '240', template: '+### ############' },
{ name: 'Eritrea', iso2: 'er', code: '291', template: '+### ############' },
{ name: 'Estonia', iso2: 'ee', code: '372', template: '+### #### ######' },
{ name: 'Ethiopia', iso2: 'et', code: '251', template: '+### ############' },
{ name: 'Fiji', iso2: 'fj', code: '679', template: '+### ############' },
{ name: 'Finland', iso2: 'fi', code: '358', template: '+### ## ### ## ##' },
{ name: 'France', iso2: 'fr', code: '33', template: '+## # ## ## ## ##' },
{ name: 'French Guiana', iso2: 'gf', code: '594', template: '+### ############' },
{ name: 'French Polynesia', iso2: 'pf', code: '689', template: '+### ############' },
{ name: 'Gabon', iso2: 'ga', code: '241', template: '+### ############' },
{ name: 'Gambia', iso2: 'gm', code: '220', template: '+### ############' },
{ name: 'Georgia', iso2: 'ge', code: '995', template: '+### ############' },
{ name: 'Germany', iso2: 'de', code: '49', template: '+## #### ########' },
{ name: 'Ghana', iso2: 'gh', code: '233', template: '+### ############' },
{ name: 'Greece', iso2: 'gr', code: '30', template: '+## #############' },
{ name: 'Grenada', iso2: 'gd', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['473'] },
{ name: 'Guadeloupe', iso2: 'gp', code: '590', template: '+### ############' },
{ name: 'Guam', iso2: 'gu', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['671'] },
{ name: 'Guatemala', iso2: 'gt', code: '502', template: '+### ####-####' },
{ name: 'Guinea', iso2: 'gn', code: '224', template: '+### ############' },
{ name: 'Guinea-Bissau', iso2: 'gw', code: '245', template: '+### ############' },
{ name: 'Guyana', iso2: 'gy', code: '592', template: '+### ############' },
{ name: 'Haiti', iso2: 'ht', code: '509', template: '+### ####-####' },
{ name: 'Honduras', iso2: 'hn', code: '504', template: '+### ############' },
{ name: 'Hong Kong', iso2: 'hk', code: '852', template: '+### #### ####' },
{ name: 'Hungary', iso2: 'hu', code: '36', template: '+## #############' },
{ name: 'Iceland', iso2: 'is', code: '354', template: '+### ### ####' },
{ name: 'India', iso2: 'in', code: '91', template: '+## #####-#####' },
{ name: 'Indonesia', iso2: 'id', code: '62', template: '+## #############' },
{ name: 'Iran', iso2: 'ir', code: '98', template: '+## ### ### ####' },
{ name: 'Iraq', iso2: 'iq', code: '964', template: '+### ############' },
{ name: 'Ireland', iso2: 'ie', code: '353', template: '+### ## #######' },
{ name: 'Israel', iso2: 'il', code: '972', template: '+### ### ### ####' },
{ name: 'Italy', iso2: 'it', code: '39', template: '+## ### #######' },
{ name: 'Jamaica', iso2: 'jm', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['876'] },
{ name: 'Japan', iso2: 'jp', code: '81', template: '+## ## #### ####' },
{ name: 'Jordan', iso2: 'jo', code: '962', template: '+### ############' },
{ name: 'Kazakhstan', iso2: 'kz', code: '7', template: '+# (###) ###-##-##', priority: 1, areaCodes: ['310', '311', '312', '313', '315', '318', '321', '324', '325', '326', '327', '336', '7172', '73622'] },
{ name: 'Kenya', iso2: 'ke', code: '254', template: '+### ############' },
{ name: 'Kiribati', iso2: 'ki', code: '686', template: '+### ############' },
{ name: 'Kosovo', iso2: 'xk', code: '383', template: '+### ############' },
{ name: 'Kuwait', iso2: 'kw', code: '965', template: '+### ############' },
{ name: 'Kyrgyzstan', iso2: 'kg', code: '996', template: '+### ### ### ###' },
{ name: 'Laos', iso2: 'la', code: '856', template: '+### ############' },
{ name: 'Latvia', iso2: 'lv', code: '371', template: '+### ## ### ###' },
{ name: 'Lebanon', iso2: 'lb', code: '961', template: '+### ############' },
{ name: 'Lesotho', iso2: 'ls', code: '266', template: '+### ############' },
{ name: 'Liberia', iso2: 'lr', code: '231', template: '+### ############' },
{ name: 'Libya', iso2: 'ly', code: '218', template: '+### ############' },
{ name: 'Liechtenstein', iso2: 'li', code: '423', template: '+### ############' },
{ name: 'Lithuania', iso2: 'lt', code: '370', template: '+### ############' },
{ name: 'Luxembourg', iso2: 'lu', code: '352', template: '+### ############' },
{ name: 'Macau', iso2: 'mo', code: '853', template: '+### ############' },
{ name: 'Macedonia', iso2: 'mk', code: '389', template: '+### ############' },
{ name: 'Madagascar', iso2: 'mg', code: '261', template: '+### ############' },
{ name: 'Malawi', iso2: 'mw', code: '265', template: '+### ############' },
{ name: 'Malaysia', iso2: 'my', code: '60', template: '+## ##-####-####' },
{ name: 'Maldives', iso2: 'mv', code: '960', template: '+### ############' },
{ name: 'Mali', iso2: 'ml', code: '223', template: '+### ############' },
{ name: 'Malta', iso2: 'mt', code: '356', template: '+### ############' },
{ name: 'Marshall Islands', iso2: 'mh', code: '692', template: '+### ############' },
{ name: 'Martinique', iso2: 'mq', code: '596', template: '+### ############' },
{ name: 'Mauritania', iso2: 'mr', code: '222', template: '+### ############' },
{ name: 'Mauritius', iso2: 'mu', code: '230', template: '+### ############' },
{ name: 'Mexico', iso2: 'mx', code: '52', template: '+## ### ### ####' },
{ name: 'Micronesia', iso2: 'fm', code: '691', template: '+### ############' },
{ name: 'Moldova', iso2: 'md', code: '373', template: '+### (##) ##-##-##' },
{ name: 'Monaco', iso2: 'mc', code: '377', template: '+### ############' },
{ name: 'Mongolia', iso2: 'mn', code: '976', template: '+### ############' },
{ name: 'Montenegro', iso2: 'me', code: '382', template: '+### ############' },
{ name: 'Morocco', iso2: 'ma', code: '212', template: '+### ############' },
{ name: 'Mozambique', iso2: 'mz', code: '258', template: '+### ############' },
{ name: 'Myanmar', iso2: 'mm', code: '95', template: '+## #############' },
{ name: 'Namibia', iso2: 'na', code: '264', template: '+### ############' },
{ name: 'Nauru', iso2: 'nr', code: '674', template: '+### ############' },
{ name: 'Nepal', iso2: 'np', code: '977', template: '+### ############' },
{ name: 'Netherlands', iso2: 'nl', code: '31', template: '+## ## ########' },
{ name: 'New Caledonia', iso2: 'nc', code: '687', template: '+### ############' },
{ name: 'New Zealand', iso2: 'nz', code: '64', template: '+## ###-###-####' },
{ name: 'Nicaragua', iso2: 'ni', code: '505', template: '+### ############' },
{ name: 'Niger', iso2: 'ne', code: '227', template: '+### ############' },
{ name: 'Nigeria', iso2: 'ng', code: '234', template: '+### ############' },
{ name: 'North Korea', iso2: 'kp', code: '850', template: '+### ############' },
{ name: 'Norway', iso2: 'no', code: '47', template: '+## ### ## ###' },
{ name: 'Oman', iso2: 'om', code: '968', template: '+### ############' },
{ name: 'Pakistan', iso2: 'pk', code: '92', template: '+## ###-#######' },
{ name: 'Palau', iso2: 'pw', code: '680', template: '+### ############' },
{ name: 'Palestine', iso2: 'ps', code: '970', template: '+### ############' },
{ name: 'Panama', iso2: 'pa', code: '507', template: '+### ############' },
{ name: 'Papua New Guinea', iso2: 'pg', code: '675', template: '+### ############' },
{ name: 'Paraguay', iso2: 'py', code: '595', template: '+### ############' },
{ name: 'Peru', iso2: 'pe', code: '51', template: '+## #############' },
{ name: 'Philippines', iso2: 'ph', code: '63', template: '+## #### #######' },
{ name: 'Poland', iso2: 'pl', code: '48', template: '+## ###-###-###' },
{ name: 'Portugal', iso2: 'pt', code: '351', template: '+### ############' },
{ name: 'Puerto Rico', iso2: 'pr', code: '1', template: '+# (###) ###-####', priority: 3, areaCodes: ['787', '939'] },
{ name: 'Qatar', iso2: 'qa', code: '974', template: '+### ############' },
{ name: 'Réunion', iso2: 're', code: '262', template: '+### ############' },
{ name: 'Romania', iso2: 'ro', code: '40', template: '+## #############' },
{ name: 'Russia', iso2: 'ru', code: '7', template: '+# (###) ###-##-##' },
{ name: 'Rwanda', iso2: 'rw', code: '250', template: '+### ############' },
{ name: 'Saint Kitts and Nevis', iso2: 'kn', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['869'] },
{ name: 'Saint Lucia', iso2: 'lc', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['758'] },
{ name: 'Saint Vincent and the Grenadines', iso2: 'vc', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['784'] },
{ name: 'Samoa', iso2: 'ws', code: '685', template: '+### ############' },
{ name: 'San Marino', iso2: 'sm', code: '378', template: '+### ############' },
{ name: 'São Tomé and Príncipe', iso2: 'st', code: '239', template: '+### ############' },
{ name: 'Saudi Arabia', iso2: 'sa', code: '966', template: '+### ############' },
{ name: 'Senegal', iso2: 'sn', code: '221', template: '+### ############' },
{ name: 'Serbia', iso2: 'rs', code: '381', template: '+### ############' },
{ name: 'Seychelles', iso2: 'sc', code: '248', template: '+### ############' },
{ name: 'Sierra Leone', iso2: 'sl', code: '232', template: '+### ############' },
{ name: 'Singapore', iso2: 'sg', code: '65', template: '+## ####-####' },
{ name: 'Slovakia', iso2: 'sk', code: '421', template: '+### ############' },
{ name: 'Slovenia', iso2: 'si', code: '386', template: '+### ############' },
{ name: 'Solomon Islands', iso2: 'sb', code: '677', template: '+### ############' },
{ name: 'Somalia', iso2: 'so', code: '252', template: '+### ############' },
{ name: 'South Africa', iso2: 'za', code: '27', template: '+## #############' },
{ name: 'South Korea', iso2: 'kr', code: '82', template: '+## ### #### ####' },
{ name: 'South Sudan', iso2: 'ss', code: '211', template: '+### ############' },
{ name: 'Spain', iso2: 'es', code: '34', template: '+## ### ### ###' },
{ name: 'Sri Lanka', iso2: 'lk', code: '94', template: '+## #############' },
{ name: 'Sudan', iso2: 'sd', code: '249', template: '+### ############' },
{ name: 'Suriname', iso2: 'sr', code: '597', template: '+### ############' },
{ name: 'Swaziland', iso2: 'sz', code: '268', template: '+### ############' },
{ name: 'Sweden', iso2: 'se', code: '46', template: '+## (###) ###-###' },
{ name: 'Switzerland', iso2: 'ch', code: '41', template: '+## ## ### ## ##' },
{ name: 'Syria', iso2: 'sy', code: '963', template: '+### ############' },
{ name: 'Taiwan', iso2: 'tw', code: '886', template: '+### ############' },
{ name: 'Tajikistan', iso2: 'tj', code: '992', template: '+### ############' },
{ name: 'Tanzania', iso2: 'tz', code: '255', template: '+### ############' },
{ name: 'Thailand', iso2: 'th', code: '66', template: '+## #############' },
{ name: 'Timor-Leste', iso2: 'tl', code: '670', template: '+### ############' },
{ name: 'Togo', iso2: 'tg', code: '228', template: '+### ############' },
{ name: 'Tonga', iso2: 'to', code: '676', template: '+### ############' },
{ name: 'Trinidad and Tobago', iso2: 'tt', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['868'] },
{ name: 'Tunisia', iso2: 'tn', code: '216', template: '+### ############' },
{ name: 'Turkey', iso2: 'tr', code: '90', template: '+## ### ### ## ##' },
{ name: 'Turkmenistan', iso2: 'tm', code: '993', template: '+### ############' },
{ name: 'Tuvalu', iso2: 'tv', code: '688', template: '+### ############' },
{ name: 'Uganda', iso2: 'ug', code: '256', template: '+### ############' },
{ name: 'Ukraine', iso2: 'ua', code: '380', template: '+### (##) ### ## ##' },
{ name: 'United Arab Emirates', iso2: 'ae', code: '971', template: '+### ############' },
{ name: 'United Kingdom', iso2: 'gb', code: '44', template: '+## #### ######' },
{ name: 'United States', iso2: 'us', code: '1', template: '+# (###) ###-####' },
{ name: 'Uruguay', iso2: 'uy', code: '598', template: '+### ############' },
{ name: 'Uzbekistan', iso2: 'uz', code: '998', template: '+### ## ### ## ##' },
{ name: 'Vanuatu', iso2: 'vu', code: '678', template: '+### ############' },
{ name: 'Vatican City', iso2: 'va', code: '39', template: '+## ### #######', priority: 1 },
{ name: 'Venezuela', iso2: 've', code: '58', template: '+## #############' },
{ name: 'Vietnam', iso2: 'vn', code: '84', template: '+## #############' },
{ name: 'Yemen', iso2: 'ye', code: '967', template: '+### ############' },
{ name: 'Zambia', iso2: 'zm', code: '260', template: '+### ############' },
{ name: 'Zimbabwe', iso2: 'zw', code: '263', template: '+### ############' },
];