inputやtextarea要素に対して数値入力を行うUI

`input`、`textarea`要素に対して数値入力を行うためのテンキー型のUIです。

**[動作デモ](https://akebi.jp/temp/numkey_ui/)**

###適用方法###

対応させたい`input`、`textarea`要素のHTMLタグに`data-numKeyPad`を追記することで適用できます。

~~~html
<input type='text' data-numKeyPad>
~~~

該当要素をクリックすることでUIが表示されます。
![01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/0ca9eccb-3c96-cf74-12ba-926b75495858.jpeg)

`data-numKeyPad`に`numeric`を指定、または日時入力系のtypeに対して指定した場合は、数字とバックスペースキーのみのレイアウトになります。

~~~html
<input type='text' data-numKeyPad='numeric'>
<input type='time' data-numKeyPad>
~~~
![02.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/5e73163a-c573-b72f-c82a-766a7bfc87f2.jpeg)

`data-numKeyPad`に`numeric`を指定した場合は**/*-+.**キーが省略されたレイアウトになりますが、`forced`で追加表示指定ができます。
**numericを指定しつつ '.' と '-' を追加する例**

~~~html
<input type='text' data-numKeyPad='numeric; forced:".-"'>
~~~

![03.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/a6366807-566e-b88c-b645-d302a4f485d6.jpeg)
スマートフォンではUI上のキータップ、PCではそれに加えてキーボードで入力可能です。
UIの表示位置はドラッグで変更可能、UI以外の部分をタップorクリック、またはEnterキーでクローズします。

###スクリプト###

~~~js:numkeypad.js
'use strict';
{
    const
        obj = {},
        opt = {},
        handler = (function() {
            const events = {};
            let key = 1;
            return {
                add: function(target, type, listener, capture) {
                    target.addEventListener(type, listener, capture);
                    events[key] = {
                        target: target,
                        type: type,
                        listener: listener,
                        capture: capture
                    };
                    return key++;
                },
                remove: function(key) {
                    if(key in events) {
                        const e = events[key];
                        e.target.removeEventListener(e.type, e.listener, e.capture);
                        delete(events[key]);
                    }
                }
            };
        }()),
        touchFlg = window.ontouchstart !== undefined ? true : false;

    window.addEventListener('DOMContentLoaded', function() {
        document.querySelector('body').insertAdjacentHTML('beforeend',
            "<div id='numKeyPad' hidden>" +
            "    <div id='nkpBackground'></div>" +
            "    <div id='nkpPad'>" +
            "        <input type='text' id='nkpText'>" +
            "        <input type='checkbox' id='nkpCb'>" +
            "        <div class='nkpL nkpNan'>" +
            "            <button id='nkpKeySlash'>/</button>" +
            "            <button id='nkpKeyAsterisk'>*</button>" +
            "            <button id='nkpKeyBackspace' class='nkpWl'>&lt;</button>" +
            "        </div>" +
            "        <div class='nkpL'>" +
            "            <button id='nkpKey7'>7</button>" +
            "            <button id='nkpKey8'>8</button>" +
            "            <button id='nkpKey9'>9</button>" +
            "            <button id='nkpKeyHyphen' class='nkpVl nkpNan'>-</button>" +
            "            <div class='nkpForce'>" +
            "                <button id='nkpKeySlash'>/</button>" +
            "                <button id='nkpKeyAsterisk'>*</button>" +
            "                <button id='nkpKeyHyphen'>-</button>" +
            "                <button id='nkpKeyPlus'>+</button>" +
            "            </div>" +
            "        </div>" +
            "        <div class='nkpL'>" +
            "            <button id='nkpKey4'>4</button>" +
            "            <button id='nkpKey5'>5</button>" +
            "            <button id='nkpKey6'>6</button>" +
            "        </div>" +
            "        <div class='nkpL'>" +
            "            <button id='nkpKey1'>1</button>" +
            "            <button id='nkpKey2'>2</button>" +
            "            <button id='nkpKey3'>3</button>" +
            "            <button id='nkpKeyPlus' class='nkpVl nkpNan'>+</button>" +
            "        </div>" +
            "        <div class='nkpL'>" +
            "            <button id='nkpKey0' class='nkpWl'>0</button>" +
            "            <button id='nkpKeyPeriod'>.</button>" +
            "            <button id='nkpKeyBackspace' class='nkpNum'>&lt;</button>" +
            "        </div>" +
            "    </div>" +
            "</div>");

        const e = document.querySelectorAll('#numKeyPad [id^="nkpKey"]');
        for(let i in e) {
            if(typeof e[i] !== 'object') continue;
            e[i].addEventListener(touchFlg ? 'touchstart' : 'mousedown', function() {
                obj.inputId = this.id;
            }, false);
            e[i].addEventListener(touchFlg ? 'touchend' : 'mouseup', function() {
                setTimeout(function(){
                    document.querySelector('#numKeyPad #nkpCb').focus();
                    if(obj.releaseTime !== undefined && Date.now() - obj.releaseTime < 100) return;
                    padKeyInput(obj.inputId);
                });
            }, false);
        }
        const bg = document.querySelector('#numKeyPad #nkpBackground');
        bg.style.position = 'fixed';
        bg.style.top = '0px';
        bg.style.left = '0px';
        bg.style.width = '100%';
        bg.style.height = '100%';
        bg.addEventListener('click', function(){
            closeNumKeyPad();
        }, false);

        const t =  document.querySelectorAll('[data-numKeyPad]');
        for(let i in t) {
            if(t[i].nodeName === undefined) continue;
            if(['input', 'textarea'].indexOf(t[i].nodeName.toLowerCase()) < 0) continue;
            t[i].addEventListener(touchFlg ? 'touchstart' : 'mousedown', function(){
                if(/(time|date)/.test(this.type)) {
                    if(touchFlg) this.disabled = true;
                }
            }, false);
            t[i].addEventListener(touchFlg ? 'touchend' : 'mouseup', function(){
                if(/(time|date)/.test(this.type)) {
                    if(!touchFlg) setTimeout(function(){this.blur()},500);
                }
                viewNumKeyPad(this);
                this.disabled = false;
            }, false);
            if(touchFlg) {
                t[i].addEventListener('click', function(){
                    this.disabled = true;
                    viewNumKeyPad(this);
                    this.disabled = false;
                }, false);
            }
        }

        const np = document.querySelector('#numKeyPad #nkpPad');
        np.addEventListener(touchFlg ? 'touchstart' : 'mousedown', function(e) {
            obj.moveFlg = true;
            obj.x = (touchFlg ? e.touches[0].clientX : e.clientX);
            obj.y = (touchFlg ? e.touches[0].clientY : e.clientY);
            obj.sx = obj.x - this.offsetLeft;
            obj.sy = obj.y - this.offsetTop;
        }, false);

        window.addEventListener(touchFlg ? 'touchmove' : 'mousemove', function(e) {
            if(obj.moveFlg) {
                obj.ex = touchFlg ? e.touches[0].clientX : e.clientX;
                obj.ey = touchFlg ? e.touches[0].clientY : e.clientY;
                if(Math.abs(obj.ex - obj.x) > 24 || Math.abs(obj.ey - obj.y) > 24 || obj.moved) {
                    const
                        rx = obj.ex - obj.sx,
                        ry = obj.ey - obj.sy;
                    np.style.left = rx + 'px';
                    np.style.top = ry + 'px';
                    obj.moved = true;
                }
            }
        }, false);

        const cb = document.querySelector('#numKeyPad #nkpCb');
        cb.style.position = 'fixed';
        cb.style.top = '-200px';

        window.addEventListener(touchFlg ? 'touchend' : 'mouseup', function(e) {
            obj.moveFlg = false;
            if(obj.moved) {
                obj.moved = false;
                obj.releaseTime = Date.now();
            }
        }, false);
    }, false);

    function padKeyInput(id) {
        if(obj.view === undefined || obj.view === false) return;
        const text = document.querySelector('#numKeyPad #nkpText');
        let tmp = text.value;
        let m;
        if((m = id.match(/^nkpKey(\d)/)) && m[1] !== undefined) tmp += m[1];
        else if(id === 'nkpKeyPeriod')    tmp += '.';
        else if(id === 'nkpKeyHyphen')    tmp += '-';
        else if(id === 'nkpKeySlash')     tmp += '/';
        else if(id === 'nkpKeyAsterisk')  tmp += '*';
        else if(id === 'nkpKeyPlus')      tmp += '+';
        else if(id === 'nkpKeyBackspace') tmp = tmp.slice(0, -1);

        if(obj.target.type === 'time') {
            tmp = tmp.replace(/\D/g, '');
            tmp = tmp.padStart(4, '0').slice(-4);
            const t = tmp.slice(0, 2) + ':' + tmp.slice(2);
            if(!isNaN(new Date('2000-01-01T' + t + ':00Z').getTime())) {
                obj.target.value = text.value = t;
                text.style.color = '';
            }
            else {
                text.value = t;
                text.style.color = '#f00';
            }
        }
        else if(obj.target.type === 'date') {
            tmp = tmp.replace(/\D/g, '');
            tmp = tmp.padStart(8, '0').slice(-8);
            const t = tmp.slice(0, 4) + '-' + tmp.slice(4, 6) + '-' + tmp.slice(6);
            if(!isNaN(new Date(t + 'T00:00:00Z').getTime())) {
                obj.target.value = text.value = t;
                text.style.color = '';
            }
            else {
                text.value = t;
                text.style.color = '#f00';
            }
        }
        else if(obj.target.type === 'month') {
            tmp = tmp.replace(/\D/g, '');
            tmp = tmp.padStart(6, '0').slice(-6);
            const t = tmp.slice(0, 4) + '-' + tmp.slice(4);
            if(!isNaN(new Date(t + '-01T00:00:00Z').getTime())) {
                obj.target.value = text.value = t;
                text.style.color = '';
            }
            else {
                text.value = t;
                text.style.color = '#f00';
            }
        }
        else if(obj.target.type === 'datetime-local') {
            tmp = tmp.replace(/\D/g, '');
            tmp = tmp.padStart(12, '0').slice(-12);
            const d = tmp.slice(0, 4) + '-' + tmp.slice(4, 6) + '-' + tmp.slice(6, 8);
            const t = tmp.slice(8, 10) + ':' + tmp.slice(10);
            if(!isNaN(new Date(d + 'T' + t + 'Z').getTime())) {
                obj.target.value = d + 'T' + t;
                text.value = d + ' ' + t;
                text.style.color = '';
            }
            else {
                text.value = d + ' ' + t;
                text.style.color = '#f00';
            }
        }
        else if(obj.target.type === 'number') {
            text.style.color = '';
            if(tmp === '') {
                text.value = tmp;
                obj.target.value = '0';
            }
            else if(tmp.length > 1 && tmp[0] === '0' && tmp[1] !== '.') {
                obj.target.value = text.value = tmp.slice(1);
            }
            else if(String(parseFloat(tmp)) === String(tmp)) {
                obj.target.value = text.value = tmp;
            }
            else if(!isNaN(tmp) || tmp === '-') {
                text.value = tmp;
                text.style.color = '#f00';
            }
            else if(tmp.length > 1 && tmp[0] !== '-' && tmp.slice(-1) === '-') {
                obj.target.value = text.value = tmp.slice(-1) + tmp.slice(0, -1);
            }
            else if(tmp.length > 1 && tmp[0] === '-' && tmp.slice(-1) === '-') {
                obj.target.value = text.value = tmp.slice(1, -1);
            }
        }
        else {
            obj.target.value = text.value = tmp;
        }
    }

    window.addEventListener('keydown', function(e) {
        if(obj.view === undefined || obj.view === false) return;
        const key = e.key.toLowerCase();
        function isAvailable(s) {
            return !opt.numeric || opt.forcedKey[s];
        }
        if(key === 'enter') {
            closeNumKeyPad();
            return;
        }
        else if(key >= '0'  && key <= '9')              padKeyInput('nkpKey' + key);
        else if(key === '.' && isAvailable('Period'))   padKeyInput('nkpKeyPeriod');
        else if(key === '-' && isAvailable('Hyphen'))   padKeyInput('nkpKeyHyphen');
        else if(key === '+' && isAvailable('Plus'))     padKeyInput('nkpKeyPlus');
        else if(key === '/' && isAvailable('Slash'))    padKeyInput('nkpKeySlash');
        else if(key === '*' && isAvailable('Asterisk')) padKeyInput('nkpKeyAsterisk');
        else if(key === 'backspace')                    padKeyInput('nkpKeyBackspace');
    }, false);

    function viewNumKeyPad(e) {
        const pad   = document.querySelector('#numKeyPad');
        const np    = document.querySelector('#numKeyPad #nkpPad');
        const text  = document.querySelector('#numKeyPad #nkpText');
        const force = document.querySelector('#numKeyPad .nkpForce');
        const eValue = e.value;
        text.value = e.value;

        const numKeySetParams = e.dataset['numkeypad'].split(';');
        opt.numeric = /(time|date|month)/.test(e.type) ? 1 : 0;
        opt.forced = 0;
        opt.forcedKey = {};
        const forcedKeys = {'/': 'Slash', '*': 'Asterisk', '-': 'Hyphen', '+': 'Plus', '.': 'Period'};
        for(let i in forcedKeys) {
            opt.forcedKey[forcedKeys[i]] = false;
        }

        for(let i = 0; i < numKeySetParams.length; ++i) {
            const param = numKeySetParams[i].trim();
            const name = param.split(':')[0].toLowerCase();
            const value = param.split(':')[1];
            if(name === 'numeric') opt.numeric = 1;
            if(name === 'forced') {
                for(let i in forcedKeys) {
                    if(value.indexOf(i) !== -1) {
                        opt.forcedKey[forcedKeys[i]] = true;
                        opt.forced++;
                    }
                }
            }
        }
        force.style.display = opt.numeric && opt.forced ? 'block' : 'none';
        pad.querySelector('#nkpKeyPeriod').style.display =
            !opt.numeric || opt.forcedKey['Period'] ? 'block' : 'none';
        pad.querySelector('#nkpKey0').style.width =
            opt.numeric && opt.forcedKey['Period'] ? '60px' : '124px';
        for(let i in opt.forcedKey) {
            if(i !== 'Period') {
                pad.querySelector('.nkpForce #nkpKey' + i).style.display =
                    opt.forcedKey[i] ? 'block' : 'none';
            }
        }

        const nanKeys = pad.querySelectorAll('.nkpNan,.nkpNum');
        for(let i in nanKeys) {
            if(typeof nanKeys[i] !== 'object') continue;
            let flg = opt.numeric;
            if(nanKeys[i].className.indexOf('nkpNum') !== -1) flg ^= 1;
            nanKeys[i].style.display = flg ? 'none' : '';
        }

        if(/(time|date|number|month)/.test(e.type)) {
            text.style.position = 'relative';
            text.style.top = '';
            text.style.color = '';
            text.readOnly = true;
            const ph = {
                time: 'HH:MM',
                date: 'YYYY-MM-DD',
                'datetime-local': 'YYYY-MM-DD HH:MM',
                month: 'YYYY-MM',
            };
            text.placeholder = ph[e.type] !== undefined ? ph[e.type] : '';
            text.value = text.value.replace(/[^\d-:\/.]/g, ' ');

            if(!touchFlg && e.type !== 'number') {
                const now = Date.now();
                function blur() {
                    if(Date.now() - now < 1000) {
                        setTimeout(function(){
                            e.blur();
                            blur();
                            e.value = eValue;
                        },100);
                    }
                }
                blur();
            }
        }
        else {
            text.style.position = 'absolute';
            text.style.top = '-100px';
        }

        np.style.overflow = 'hidden';
        np.style.top = (6 + e.offsetTop + e.clientHeight) + 'px';
        np.style.left = e.offsetLeft + 'px';

        pad.style.display = 'block';
        disabledDefaultPicker(e);

        obj.targetStyleBackBorder = e.style.border;
        e.style.border = '2px solid black';

        obj.target = e;
        obj.view = true;

        obj.dblclickId = handler.add(np, 'dblclick', function(e) {
            e.preventDefault();
        }, { passive: false });
        obj.touchmoveId = handler.add(np, 'touchmove', function(e) {
            e.preventDefault();
        }, { passive: false });

        if(!touchFlg) {
            document.querySelector('#numKeyPad #nkpCb').type = 'text';
            if(/(date|time)/.test(e.type)) {
                const typeBuf = e.type;
                e.type = 'text';
                setTimeout(function(){
                    e.type = typeBuf;
                });
            }
        }
        setTimeout(function(){
            document.querySelector('#numKeyPad #nkpCb').focus();
        });
        obj.openTime = Date.now();
    }

    function closeNumKeyPad() {
        if(Date.now() - obj.openTime < 500) return;
        if(obj.view !== undefined && obj.view === true) {
            obj.view = false;
            document.querySelector('#numKeyPad').style.display = 'none';
        }
        if(obj.touchmoveId) {
            handler.remove(obj.touchmoveId);
            obj.touchmoveId = 0;
        }
        if(obj.dblclickId) {
            handler.remove(obj.dblclickId);
            obj.dblclickId = 0;
        }
        obj.target.style.border = obj.targetStyleBackBorder;
    }

    function disabledDefaultPicker(e) {
        if(e.tagName !== 'INPUT') return;
        const cType = e.type;
        const cn = e.cloneNode();
        e.before(cn);
        e.type = 'hidden';
        setTimeout(function() {
            e.value = cn.value;
            e.type = cType;
            cn.remove();
        });
    }
}
~~~

###CSS###

~~~css:numkeypad.css
#numKeyPad {
    user-select: none;
    -webkit-user-select: none;
    -moz-user-select: none;
}
#numKeyPad #nkpPad {
    position: absolute;
    width: auto;
    height: auto;
    border: 1px gray solid;
    border-radius: 4px;
    cursor: move;
    background: #fff;
    opacity: 0.95;
    padding: 4px 4px 0 4px;
}
#numKeyPad .nkpL {
    display: flex;
    height: 64px;
}
#numKeyPad [id^='nkpKey'] {
    box-sizing: border-box;
    width: 60px;
    height: 60px;
    margin-left: 4px;
    padding: 0px;
    font-size: 25px;
    color: gray;
    border-radius: 4px;
    border: 1px solid gray;
    background: #fff;
    -webkit-appearance: none;
    -webkit-user-select: none;
    cursor: pointer;
}
#numKeyPad [id^='nkpKey']:first-child {
    margin-left: 0px;
}
#numKeyPad .nkpVl {
    height: 124px;
}
#numKeyPad .nkpWl {
    width: 124px;
}
#numKeyPad [id='nkpText'] {
    box-sizing: border-box;
    width: 100%;
    border-radius: 4px;
    border: 1px solid gray;
    margin: 0 0 4px 0;
}

#numKeyPad .nkpForce {
    display: grid;
    margin-left: 4px;
}
#numKeyPad .nkpForce button {
    display: inline-block;
    margin-left: 0px;
    margin-bottom: 4px;
}
~~~

###使用例###

~~~html:index.html
<!DOCTYPE html>
<html lang='ja'>
<head>
    <meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1'>
    <meta charset='utf-8'>
    <link rel='stylesheet' href='./numkeypad.css'>
    <script src='./numkeypad.js'></script>
</head>
<body>
    text: <input type='text' data-numKeyPad><br>
    text: <input type='text' data-numKeyPad='numeric'>(numericを指定)<br>
    text: <input type='text' data-numKeyPad='numeric; forced:"-"'>(numericを指定 forcedで"-"を許可)<br>
    text: <input type='text' data-numKeyPad='numeric; forced:".-+"'>(numericを指定 forcedで".-+"を許可)<br>
    time: <input type='time' data-numKeyPad><br>
    date: <input type='date' data-numKeyPad><br>
    month: <input type='month' data-numKeyPad><br>
    datetime-local: <input type='datetime-local' data-numKeyPad><br>
    number: <input type='number' data-numKeyPad><br>
    textarea: <textarea data-numKeyPad></textarea><br>
</body>
</html>
~~~

本来`input`、`textarea`要素にフォーカスすると表示されるブラウザ側のソフトウェアキーボード及びデート/タイムピッカーはできるだけ表示を抑制するよう対応していますが、ブラウザごとに挙動が異なるため完全には非表示にはできず表示されてしまう場合もあります。