phone-number-input-field
Version:
HTML input component that formats and validates phone numbers.
827 lines (739 loc) • 25.8 kB
JavaScript
(function (max) {
'use strict';
var SSR_NODE = 1;
var TEXT_NODE = 3;
var EMPTY_OBJ = {};
var EMPTY_ARR = [];
var SVG_NS = "http://www.w3.org/2000/svg";
var id = (a) => a;
var map = EMPTY_ARR.map;
var isArray = Array.isArray;
var enqueue =
typeof requestAnimationFrame !== "undefined"
? requestAnimationFrame
: setTimeout;
var createClass = (obj) => {
var out = "";
if (typeof obj === "string") return obj
if (isArray(obj)) {
for (var k = 0, tmp; k < obj.length; k++) {
if ((tmp = createClass(obj[k]))) {
out += (out && " ") + tmp;
}
}
} else {
for (var k in obj) {
if (obj[k]) out += (out && " ") + k;
}
}
return out
};
var shouldRestart = (a, b) => {
for (var k in { ...a, ...b }) {
if (typeof (isArray(a[k]) ? a[k][0] : a[k]) === "function") {
b[k] = a[k];
} else if (a[k] !== b[k]) return true
}
};
var patchSubs = (oldSubs, newSubs = EMPTY_ARR, dispatch) => {
for (
var subs = [], i = 0, oldSub, newSub;
i < oldSubs.length || i < newSubs.length;
i++
) {
oldSub = oldSubs[i];
newSub = newSubs[i];
subs.push(
newSub && newSub !== true
? !oldSub ||
newSub[0] !== oldSub[0] ||
shouldRestart(newSub[1], oldSub[1])
? [
newSub[0],
newSub[1],
(oldSub && oldSub[2](), newSub[0](dispatch, newSub[1])),
]
: oldSub
: oldSub && oldSub[2]()
);
}
return subs
};
var getKey = (vdom) => (vdom == null ? vdom : vdom.key);
var patchProperty = (node, key, oldValue, newValue, listener, isSvg) => {
if (key === "style") {
for (var k in { ...oldValue, ...newValue }) {
oldValue = newValue == null || newValue[k] == null ? "" : newValue[k];
if (k[0] === "-") {
node[key].setProperty(k, oldValue);
} else {
node[key][k] = oldValue;
}
}
} else if (key[0] === "o" && key[1] === "n") {
if (
!((node.events || (node.events = {}))[(key = key.slice(2))] = newValue)
) {
node.removeEventListener(key, listener);
} else if (!oldValue) {
node.addEventListener(key, listener);
}
} else if (!isSvg && key !== "list" && key !== "form" && key in node) {
node[key] = newValue == null ? "" : newValue;
} else if (newValue == null || newValue === false) {
node.removeAttribute(key);
} else {
node.setAttribute(key, newValue);
}
};
var createNode = (vdom, listener, isSvg) => {
var props = vdom.props;
var node =
vdom.type === TEXT_NODE
? document.createTextNode(vdom.tag)
: (isSvg = isSvg || vdom.tag === "svg")
? document.createElementNS(SVG_NS, vdom.tag, props.is && props)
: document.createElement(vdom.tag, props.is && props);
for (var k in props) {
patchProperty(node, k, null, props[k], listener, isSvg);
}
for (var i = 0; i < vdom.children.length; i++) {
node.appendChild(
createNode(
(vdom.children[i] = maybeVNode(vdom.children[i])),
listener,
isSvg
)
);
}
return (vdom.node = node)
};
var patch = (parent, node, oldVNode, newVNode, listener, isSvg) => {
if (oldVNode === newVNode) ; else if (
oldVNode != null &&
oldVNode.type === TEXT_NODE &&
newVNode.type === TEXT_NODE
) {
if (oldVNode.tag !== newVNode.tag) node.nodeValue = newVNode.tag;
} else if (oldVNode == null || oldVNode.tag !== newVNode.tag) {
node = parent.insertBefore(
createNode((newVNode = maybeVNode(newVNode)), listener, isSvg),
node
);
if (oldVNode != null) {
parent.removeChild(oldVNode.node);
}
} else {
var tmpVKid;
var oldVKid;
var oldKey;
var newKey;
var oldProps = oldVNode.props;
var newProps = newVNode.props;
var oldVKids = oldVNode.children;
var newVKids = newVNode.children;
var oldHead = 0;
var newHead = 0;
var oldTail = oldVKids.length - 1;
var newTail = newVKids.length - 1;
isSvg = isSvg || newVNode.tag === "svg";
for (var i in { ...oldProps, ...newProps }) {
if (
(i === "value" || i === "selected" || i === "checked"
? node[i]
: oldProps[i]) !== newProps[i]
) {
patchProperty(node, i, oldProps[i], newProps[i], listener, isSvg);
}
}
while (newHead <= newTail && oldHead <= oldTail) {
if (
(oldKey = getKey(oldVKids[oldHead])) == null ||
oldKey !== getKey(newVKids[newHead])
) {
break
}
patch(
node,
oldVKids[oldHead].node,
oldVKids[oldHead],
(newVKids[newHead] = maybeVNode(
newVKids[newHead++],
oldVKids[oldHead++]
)),
listener,
isSvg
);
}
while (newHead <= newTail && oldHead <= oldTail) {
if (
(oldKey = getKey(oldVKids[oldTail])) == null ||
oldKey !== getKey(newVKids[newTail])
) {
break
}
patch(
node,
oldVKids[oldTail].node,
oldVKids[oldTail],
(newVKids[newTail] = maybeVNode(
newVKids[newTail--],
oldVKids[oldTail--]
)),
listener,
isSvg
);
}
if (oldHead > oldTail) {
while (newHead <= newTail) {
node.insertBefore(
createNode(
(newVKids[newHead] = maybeVNode(newVKids[newHead++])),
listener,
isSvg
),
(oldVKid = oldVKids[oldHead]) && oldVKid.node
);
}
} else if (newHead > newTail) {
while (oldHead <= oldTail) {
node.removeChild(oldVKids[oldHead++].node);
}
} else {
for (var keyed = {}, newKeyed = {}, i = oldHead; i <= oldTail; i++) {
if ((oldKey = oldVKids[i].key) != null) {
keyed[oldKey] = oldVKids[i];
}
}
while (newHead <= newTail) {
oldKey = getKey((oldVKid = oldVKids[oldHead]));
newKey = getKey(
(newVKids[newHead] = maybeVNode(newVKids[newHead], oldVKid))
);
if (
newKeyed[oldKey] ||
(newKey != null && newKey === getKey(oldVKids[oldHead + 1]))
) {
if (oldKey == null) {
node.removeChild(oldVKid.node);
}
oldHead++;
continue
}
if (newKey == null || oldVNode.type === SSR_NODE) {
if (oldKey == null) {
patch(
node,
oldVKid && oldVKid.node,
oldVKid,
newVKids[newHead],
listener,
isSvg
);
newHead++;
}
oldHead++;
} else {
if (oldKey === newKey) {
patch(
node,
oldVKid.node,
oldVKid,
newVKids[newHead],
listener,
isSvg
);
newKeyed[newKey] = true;
oldHead++;
} else {
if ((tmpVKid = keyed[newKey]) != null) {
patch(
node,
node.insertBefore(tmpVKid.node, oldVKid && oldVKid.node),
tmpVKid,
newVKids[newHead],
listener,
isSvg
);
newKeyed[newKey] = true;
} else {
patch(
node,
oldVKid && oldVKid.node,
null,
newVKids[newHead],
listener,
isSvg
);
}
}
newHead++;
}
}
while (oldHead <= oldTail) {
if (getKey((oldVKid = oldVKids[oldHead++])) == null) {
node.removeChild(oldVKid.node);
}
}
for (var i in keyed) {
if (newKeyed[i] == null) {
node.removeChild(keyed[i].node);
}
}
}
}
return (newVNode.node = node)
};
var propsChanged = (a, b) => {
for (var k in a) if (a[k] !== b[k]) return true
for (var k in b) if (a[k] !== b[k]) return true
};
var maybeVNode = (newVNode, oldVNode) =>
newVNode !== true && newVNode !== false && newVNode
? typeof newVNode.tag === "function"
? ((!oldVNode ||
oldVNode.memo == null ||
propsChanged(oldVNode.memo, newVNode.memo)) &&
((oldVNode = newVNode.tag(newVNode.memo)).memo = newVNode.memo),
oldVNode)
: newVNode
: text("");
var recycleNode = (node) =>
node.nodeType === TEXT_NODE
? text(node.nodeValue, node)
: createVNode(
node.nodeName.toLowerCase(),
EMPTY_OBJ,
map.call(node.childNodes, recycleNode),
SSR_NODE,
node
);
var createVNode = (tag, { key, ...props }, children, type, node) => ({
tag,
props,
key,
children,
type,
node,
});
var text = (value, node) =>
createVNode(value, EMPTY_OBJ, EMPTY_ARR, TEXT_NODE, node);
var h = (tag, { class: c, ...props }, children = EMPTY_ARR) =>
createVNode(
tag,
{ ...props, ...(c ? { class: createClass(c) } : EMPTY_OBJ) },
isArray(children) ? children : [children]
);
var app = ({
node,
view,
subscriptions,
dispatch = id,
init = EMPTY_OBJ,
}) => {
var vdom = node && recycleNode(node);
var subs = [];
var state;
var busy;
var update = (newState) => {
if (state !== newState) {
if ((state = newState) == null) dispatch = subscriptions = render = id;
if (subscriptions) subs = patchSubs(subs, subscriptions(state), dispatch);
if (view && !busy) enqueue(render, (busy = true));
}
};
var render = () =>
(node = patch(
node.parentNode,
node,
vdom,
(vdom = view(state)),
listener,
(busy = false)
));
var listener = function (event) {
dispatch(this.events[event.type], event);
};
return (
(dispatch = dispatch((action, props) =>
typeof action === "function"
? dispatch(action(state, props))
: isArray(action)
? typeof action[0] === "function"
? dispatch(action[0], action[1])
: action
.slice(1)
.map(
(fx) => fx && fx !== true && (fx[0] || fx)(dispatch, fx[1]),
update(action[0])
)
: update(action)
))(init),
dispatch
)
};
function dispatchEventEffect(t,{eventType:e,eventInit:n}){const s=new CustomEvent(e,n);this.dispatchEvent(s);}function setOnEventListenerEffect(t,{eventType:e,oldVal:n,newVal:s}){null!==n&&this.removeEventListener(e,n),null!==s&&this.addEventListener(e,s);}function t({app:t,init:e,view:n,subscriptions:s,dispatch:i,exposedConfig:r=[],exposedMethods:o={},useShadowDOM:a=!0,parent:c=HTMLElement}){const[p,u]=function(){const t=new Map,e=new Map;for(const n of r)n.propName&&t.set(n.propName,n),n.attrName&&e.set(n.attrName.toLowerCase(),n),"function"!=typeof n.setter&&(n.setter=n.eventType?h(n):PatchState);return [t,e]}();class CustomElement extends c{constructor(){super();const r=(a?this.attachShadow({mode:"open"}):this._fragment=document.createDocumentFragment()).appendChild(document.createElement("span")),o=this.wrapDispatch.bind(this);var c,p;"function"==typeof i?(c=o,p=i,i=function(t){return c(p(t))}):i=o,t({init:e,view:n,subscriptions:s,dispatch:i,node:r});}connectedCallback(){a||c!==HTMLElement||this.appendChild(this._fragment);}disconnectedCallback(){this._dispatch(),this._dispatch=void 0,this._fragment=void 0;}wrapDispatch(t){const e=t=>{const e=t.bind(this);return t.$isWrapped&&(e.$isWrapped=!0),e};return this._dispatch=t,(n,s)=>{let i;if("function"==typeof n)n=e(n);else if(Array.isArray(n))if("function"==typeof n[0])n[0]=e(n[0]);else {i=n[0];for(let t=1;t<n.length;t++){const s=n[t];Array.isArray(s)?s[0]=e(s[0]):n[t]=e(s);}}else i=n;void 0!==i&&(this._state=i),t(n,s),void 0!==i&&this.syncAttributes();}}dispatchAction(t,e){this._dispatch(t,e);}syncAttributes(){for(const t in this._state)if(p.has(t)){const e=p.get(t);e.attrName&&this.syncAttribute(e,this._state[t]);}}syncAttribute(t,e){if(t.eventType)return;const n=t.attrName;"boolean"==typeof e?e?this.setAttribute(n,""):this.removeAttribute(n):null==e||""===e?this.removeAttribute(n):this.setAttribute(n,e);}getProperty(t){return (p.get(t).getter||(e=>e?.[t]))(this._state)}setProperty(t,e){const n=p.get(t).setter;this.dispatchAction(n,{[t]:e});}attributeChangedCallback(t,e,n){if(e===n)return;const s=u.get(t.toLowerCase()),i=s.setter,r=s.propName||s.attrName;(""===n&&null===e||null===n&&""===e||null===n&&e===t||n===t&&null===e)&&(n=!(null===n)),this.dispatchAction(i,{[r]:n});}static get observedAttributes(){return u.keys()}}function PatchState(t,e){return {...t,...e}}function h({propName:t,attrName:e,eventType:n}){return function SetOnEventHandler(s,i){const r=t||e;let o=i[r];o&&"function"!=typeof o&&(o=new Function("event",o),Object.defineProperty(o,"name",{value:r}));const a=s[r];return [{...s,[r]:o},[setOnEventListenerEffect,{eventType:n,oldVal:a,newVal:o}]]}}return p.forEach(((t,e)=>{const n={configurable:!1,enumerable:!0,get(){return this.getProperty(e)},set(t){this.setProperty(e,t);}};Object.defineProperty(CustomElement.prototype,e,n);})),function(){for(const t in o)CustomElement.prototype[t]=function(){this.dispatchAction(o[t]);};}(),CustomElement}
function eventSubscriber(dispatch, { target, eventType, action }) {
target = target || window;
target.addEventListener(eventType, handleEvent);
return () => {
target.removeEventListener(eventType, handleEvent);
};
function handleEvent(event) {
dispatch([action, event]);
}
}
/**
* Effect that initialises a native element's built-in properties.
*/
function initNativeProperties(_, props) {
for (const propName in props) {
this[propName] = props[propName];
}
}
/**
* Sets the native input control's validity status. This affects the :valid and
* :invalid CSS pseudo-classes and form validation.
*
* @param {function} dispatch
* @param {Object} props
* @param {HTMLInputElement} props.input The input element instance.
* @param {boolean} props.isValid Whether the value is considered valid.
* @param {string} props.errorMsg An error message that will be displayed when
* the form is validated by the browser prior to submission.
*/
function setValidity(dispatch, { input, isValid, errorMsg }) {
input.setCustomValidity(isValid ? '' : errorMsg);
}
/**
* The default number of milliseconds to wait before invoking an action.
*/
const DEFAULT_INTERVAL = 250;
/**
* Creates a debounced version of an original Hyperapp action.
*
* Strategy:
* 1. Construct and return an action that does nothing but trigger an effect.
* 2. Configure the effect to dispatch the original action after the specified
* interval.
*
* @param {function} action The action to invoke in a debounced way.
* @param {number} [interval] The number of milliseconds to wait for another
* invocation before triggering the action.
* @returns {function} a replacement action.
*/
function debounce(action, interval = DEFAULT_INTERVAL) {
return function (state, props) {
const effect = [debounceEffect, { action, props, interval }];
return [state, effect];
};
}
/**
* A map of currently scheduled timeouts. The keys of the map are the Action
* functions for which timeouts are scheduled. Having objects as keys is a very
* convenient property of WeakMaps.
*/
const pendingTimeouts = new WeakMap();
/**
* This function is a Hyperapp effect that invokes an action after a specified
* interval. The interval is restarted if the effect is invoked again for the
* same action before the action itself has been invoked.
*
* Strategy:
* 1. The first time this is called for a specific action, set a timeout.
* 2. Maintain a map of pending timeouts, indexed by action.
* 3. On subsequent calls for the same action, restart the timeout if it hasn't
* yet fired.
*
* @param {function} dispatch
* @param {Object} props
* @param {function} props.action The action to dispatch after the interval.
* @param {*} props.payload The argument to call the action with.
* @param {number} props.interval The interval (milliseconds) after which to
* dispatch the action.
*/
function debounceEffect(dispatch, { action, props, interval }) {
// If the action has already been scheduled, restart the interval.
cancelAction(action);
const handle = setTimeout(() => {
pendingTimeouts.delete(action);
dispatch(action, props);
}, interval);
pendingTimeouts.set(action, handle);
}
/**
* Cancels a specific pending action. This should be done when a state change
* means that it would be inappropriate to follow through with the specific
* pending action that was submitted before the state change.
*
* @param {function} action The action to cancel.
*/
function cancelAction(action) {
const handle = pendingTimeouts.get(action);
if (handle) {
clearTimeout(handle);
pendingTimeouts.delete(action);
}
}
const PARSE = 'phone-parse';
const COUNTRY_CHANGE = 'phone-country-change';
const INPUT_DEBOUNCE_INTERVAL = 250;
const ERROR_INVALID_PHONE_NUMBER = 'Invalid phone number!';
const E164 = 'E.164';
/**
* Parses and validates the supplied phone number, then dispatches the specified
* action.
*
* @param {function} dispatch
* @param {Object} props
* @param {string} props.defaultCountry
* @param {string} props.value The phone number that needs to be parsed.
* @param {function} props.action The action to dispatch.
*/
function parsePhoneNumber(dispatch, { defaultCountry, value, action }) {
// Attempt to parse the phone number.
const phone = max.parsePhoneNumberFromString(value, defaultCountry);
const parsed = {
country: phone?.country,
phoneIsPossible: phone?.isPossible() || false,
phoneIsValid: phone?.isValid() || false,
phoneType: phone?.getType(),
phoneE164: phone?.format(E164),
};
dispatch(action, parsed);
}
/**
* Effect that adds spaces or hyphens at appropriate places as the number is
* being entered.
*
* @param {function} dispatch
* @param {Object} props
* @param {string} props.defaultCountry The 2-letter code of the default country
* @param {HTMLInputElement} props.input The input element instance.
*/
function formatPhoneNumber(dispatch, { defaultCountry, input }) {
// Determine the appropriate formatting for the number as entered so far.
const formatted = new max.AsYouType(defaultCountry).input(input.value);
if (input.value !== formatted) {
// Punctuation is about to be added. If the cursor is not at the end of the
// input field, it will jump to the end. We therefore have to save and
// restore its position.
const start = input.selectionStart;
const end = input.selectionEnd;
const shouldMoveCursor = start !== input.value.length;
input.value = formatted;
if (shouldMoveCursor) {
input.setSelectionRange(start, end);
}
}
}
/**
* Creates and configures an effect tuple that will set the native element's
* validity status.
*
* @param {Object} props The properties are named so that the current state can
* be passed in.
* @param {HTMLInputElement} props.self
* @param {boolean} props.phoneIsValid
* @param {string} props.errorMsg
*/
function createSetValidityEffect({ self, phoneIsValid, errorMsg }) {
return [
setValidity,
{
input: self,
isValid: phoneIsValid,
errorMsg: errorMsg || ERROR_INVALID_PHONE_NUMBER,
},
];
}
/**
* Initialises the element.
*
* @param {Object} state
* @returns {Array}
*/
function InitialiseState(state = {}) {
// We add a reference to the element itself so that subscriber functions can
// easily use it as a target for registering event listeners.
const newState = {
...state,
self: this,
};
// Set the native field's type to "tel" so that the mobile keyboard
// adapts to show only digits.
const effect = [initNativeProperties, { type: 'tel' }];
return [newState, effect];
}
/**
* Sets the default country to use when parsing the phone number.
*
* @param {Object} state
* @param {Object} props
* @param {string} props.defaultCountry
* @returns {Array}
*/
function SetDefaultCountry(state, { defaultCountry }) {
const newState = {
...state,
defaultCountry,
};
// When the default country changes, we need to reprocess the current number.
return [
newState,
[
parsePhoneNumber,
{ defaultCountry, value: newState.self.value, action: UpdatePhoneNumber },
],
];
}
/**
* Sets the error message for the browser to display when validating forms that
* include this component.
*
* @param {Object} state
* @param {Object} props
* @param {string} props.errorMsg The error message.
* @returns {Array}
*/
function SetErrorMessage(state, { errorMsg }) {
const newState = { ...state, errorMsg };
// When changing the error message, the native validity needs to be reset,
// because the error message itself is used as a validity flag by the native
// element.
const effect = createSetValidityEffect(state);
return [newState, effect];
}
/**
* Action that triggers parsing of the number that has been entered so far.
*
* @param {Object} state
* @param {InputEvent} event
* @returns {Array}
*/
function HandleInput(state, event) {
return [
state,
[
parsePhoneNumber,
{
defaultCountry: state.defaultCountry,
value: event.target.value,
action: UpdatePhoneNumber,
},
],
];
}
// Create a debounced version of HandleInput.
const DebouncedInput = debounce(HandleInput, INPUT_DEBOUNCE_INTERVAL);
/**
* Updates the state with the results of parsing and validating the contents of
* the element. Using effects, causes the native element's validity status to be
* updated, and reformats the punctuation of the number in the element.
*
* @param {Object} state
* @param {Object} props
* @param {string} props.country
* @param {boolean} props.phoneIsPossible
* @param {boolean} props.phoneIsValid
* @param {string} props.phoneType
* @param {string} props.phoneE164
* @returns {Array}
*/
function UpdatePhoneNumber(state, props) {
const newState = { ...state, ...props };
const effects = [
createSetValidityEffect(newState),
[
formatPhoneNumber,
{ defaultCountry: newState.defaultCountry, input: newState.self },
],
// Tell listeners that the number has been parsed.
[dispatchEventEffect, { eventType: PARSE }],
];
// If the country has changed, add an effect that will dispatch an event.
if (state.country !== newState.country) {
effects.push([
dispatchEventEffect,
{
eventType: COUNTRY_CHANGE,
eventInit: { detail: newState.country },
},
]);
}
return [newState, ...effects];
}
/**
* Returns an array of subscriptions.
* We need to catch the `input` events dispatched by the native InputElement
* functionality.
*
* @param {Object} state
* @returns {Array.<[function, Object]>}
*/
function subscriptions(state) {
return [
[
eventSubscriber,
{
target: state.self,
eventType: 'input',
action: DebouncedInput,
},
],
];
}
const PhoneNumberInput = t({
app,
init: [InitialiseState, {}],
view: () => h('span', {}),
subscriptions,
exposedConfig: [
{
propName: 'defaultCountry',
attrName: 'default-country',
setter: SetDefaultCountry,
},
{
propName: 'errorMsg',
attrName: 'error-msg',
setter: SetErrorMessage,
},
{
propName: 'phoneIsValid',
attrName: 'phone-is-valid',
},
{
propName: 'phoneIsPossible',
attrName: 'phone-is-possible',
},
{
propName: 'country',
attrName: 'country',
},
{
propName: 'phoneType',
attrName: 'phone-type',
},
{
propName: 'phoneE164',
attrName: 'phone-e164',
},
{
propName: 'onparse',
attrName: 'onparse',
eventType: PARSE,
},
{
propName: 'oncountrychange',
attrName: 'oncountrychange',
eventType: COUNTRY_CHANGE,
},
],
useShadowDOM: false,
parent: HTMLInputElement,
});
customElements.define('phone-number-input', PhoneNumberInput, {
extends: 'input',
});
})(libphonenumber);