UNPKG

phone-number-input-field

Version:

HTML input component that formats and validates phone numbers.

827 lines (739 loc) 25.8 kB
(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);